Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
7a9c4ea
CC-169: capture iOS App Store screenshots on a runner and upload them…
jaywedgeworth22 Oct 19, 2026
cdd9dfc
CC-169: keep the seeded App Store snapshot to platforms that render a…
jaywedgeworth22 Oct 19, 2026
5a70093
CC-169: fix the capture script bugs the review bots found
jaywedgeworth22 Oct 19, 2026
34c9b5f
CC-169: fail the capture when the pixels are not the family we booted
jaywedgeworth22 Oct 19, 2026
9c82aae
CC-169: find the iPad simulator by prefix, not by exact name
jaywedgeworth22 Oct 19, 2026
73a92de
CC-169: always regenerate the companion project before the capture
jaywedgeworth22 Oct 19, 2026
15370c5
CC-169: refuse a mixed screenshot list before anything is reserved
jaywedgeworth22 Oct 19, 2026
6a27530
CC-169: wait for the app to draw before capturing a screenshot
jaywedgeworth22 Oct 19, 2026
4b15713
CC-169: label the source scope after the device and open the alarms t…
jaywedgeworth22 Oct 19, 2026
d8e8301
autofix: pass empty launch args through Bash 3.2 set -u
github-actions[bot] Oct 20, 2026
690bec3
CC-169: keep the capture script inside what Bash 3.2 can run
jaywedgeworth22 Oct 20, 2026
597996e
CC-169: answer the eight screenshot-tooling review findings
jaywedgeworth22 Oct 20, 2026
fbe9a61
autofix: create the per-device capture directory and correct the chec…
github-actions[bot] Oct 20, 2026
279e6e9
Merge fbe9a61aa5eb97f07d1e26b2c28f7dd4abaf061e into d1e4fdd5e6117a2d4…
jaywedgeworth22 Oct 20, 2026
0dcb81e
autofix: reject a truncated PNG before reading its header
github-actions[bot] Oct 20, 2026
c6f81da
Merge 0dcb81e0d8e09efffd5b281f01464f0b53981227 into d1e4fdd5e6117a2d4…
jaywedgeworth22 Oct 20, 2026
ac5ced0
autofix: fix the real defects in the screenshot tooling review
github-actions[bot] Oct 20, 2026
a64389d
CC-169: close the second round of screenshot-tooling findings
jaywedgeworth22 Oct 20, 2026
debf87a
CC-169: close the third round of screenshot-tooling findings
jaywedgeworth22 Oct 20, 2026
ea8b466
Merge debf87aff9e62d6ba5c2e7e3f2843dd319966dca into 771b1d05296a39c69…
jaywedgeworth22 Oct 20, 2026
8e42882
autofix: keep a malformed newest-build probe out of "none"
github-actions[bot] Oct 20, 2026
db35f69
CC-169: identify the key in play with the fleet's masked preview
jaywedgeworth22 Oct 20, 2026
a1736c5
Merge db35f69c33775827e383a99228ec766ef7301567 into 771b1d05296a39c69…
jaywedgeworth22 Oct 20, 2026
22a94a9
autofix: keep the HTTP status and validate the app id on an ASC lookup
github-actions[bot] Oct 20, 2026
97367f8
CC-169: submit the version for review only when it is ready
jaywedgeworth22 Oct 20, 2026
10b330c
autofix: make the masked ASC key preview identify the key it previews
github-actions[bot] Oct 20, 2026
aa2a080
CC-169: refuse a version whose version string is not the build's
jaywedgeworth22 Oct 20, 2026
dccc12d
CC-169: merge main into the screenshot branch
jaywedgeworth22 Oct 20, 2026
a74c43a
merge main into minimax/ios-appstore-screenshots
swu-echo[bot] Oct 20, 2026
9025657
CC-169: make the submit-review gate honest about what it checked
jaywedgeworth22 Oct 20, 2026
da841d9
CC-169: correct the comment that contradicted the masked preview
jaywedgeworth22 Oct 20, 2026
93dec10
autofix: answer the four open submit-review findings
github-actions[bot] Oct 21, 2026
56eeaab
CC-169: let the upload side take a Mac screenshot too
jaywedgeworth22 Oct 21, 2026
6a6e68c
CC-169: refuse a submission when the build's marketing version is unr…
jaywedgeworth22 Oct 21, 2026
5224ad7
autofix: read the marketing version off the pre-release version
github-actions[bot] Oct 21, 2026
17103ac
CC-169: say which field the marketing version is read from
jaywedgeworth22 Oct 21, 2026
e613fdd
CC-180: pick up test rewrite + stop the "7d 7-day" widget caption
Oct 21, 2026
f29ca51
Merge origin/main into minimax/ios-appstore-screenshots
jaywedgeworth22 Oct 21, 2026
8bd9327
fix(ios-fleet): validate screenshot-set mock request body
Oct 21, 2026
61b510f
CC-180: carry the PiP test contract and the Antigravity widget captio…
jaywedgeworth22 Oct 21, 2026
726646d
CC-169: prefer a secret ASC key over a key file, and check the file f…
Oct 21, 2026
8ed5290
fix(pip): make the failed-provider fallback assertion non-vacuous
jaywedgeworth22 Oct 21, 2026
4720d14
fix(widget): keep the bar qualifier off the cadence and off a lone bar
jaywedgeworth22 Oct 21, 2026
0ab0a05
CC-169: hand the ASC key to the client through the environment
Oct 21, 2026
bd9af6d
Merge minimax/ios-appstore-fleet-secrets into minimax/ios-appstore-sc…
jaywedgeworth22 Oct 21, 2026
986b6c8
fix(ios-ship): hand the staged ASC key to the client through the envi…
Oct 21, 2026
b9e1b63
CC-169: hand the staged ASC key to the client in the child environment
Oct 21, 2026
8edd853
CC-169: bind the version to a review submission in submit-review
jaywedgeworth22 Oct 21, 2026
025fae3
CC-169: hand the staged ASC key to the two hosted-runner consumers
jaywedgeworth22 Oct 21, 2026
6ef3baa
CC-169: mark the synthetic key fixtures as scanner-approved
Oct 21, 2026
868c254
CC-169: name the widget bar qualifier from the cadence
Oct 21, 2026
7314aa3
AFC-459: hand the staged ASC key to the consumer without entering the…
Oct 21, 2026
78fb344
CC-169: check the review submission's platform before writing to it
Oct 21, 2026
3a26fd0
CC-169: name the extra allowance beside the window it doubles
Oct 21, 2026
8a8efa8
CC-169: read a cadence out of the cadence field, not out of the label
jaywedgeworth22 Oct 21, 2026
371d11e
fix(release): take the App Store Connect key from the environment only
jaywedgeworth22 Oct 21, 2026
a90d266
The caption carries the qualifier, so a surface cannot leave it out
jaywedgeworth22 Oct 21, 2026
56432e4
Merge main into the App Review branch
jaywedgeworth22 Oct 21, 2026
ebf99ba
Ship when the shared caption changes
jaywedgeworth22 Oct 21, 2026
9b472d2
Read a process's environment the way the runner can
jaywedgeworth22 Oct 21, 2026
e36c57e
Prove the two group rules the review found
jaywedgeworth22 Oct 21, 2026
5c2bf2f
Merge main into the App Review branch
Oct 21, 2026
fdc4d40
Hand the App Store Connect key to the clients through the environment
Oct 21, 2026
5329191
Merge main into the App Review branch after the squash
Oct 21, 2026
449a63c
Read the staged key's mode with the platform's own stat
Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 22 additions & 14 deletions .github/workflows/ios-ship.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ on:
- 'scripts/ios-appstore-gm-prepare.sh'
- 'scripts/ios-install-appstore-profiles.sh'
- 'scripts/ios-stage-asc-key.sh'
- 'scripts/ios-load-asc-key.sh'
workflow_dispatch:
schedule:
# Twice an hour, offset from other fleet apps
Expand Down Expand Up @@ -270,17 +271,29 @@ jobs:
fi
bash "$GITHUB_WORKSPACE/scripts/ios-appstore-gm-prepare.sh"

# The clients take the credential from the environment and never from a
# path (Kody rule 42), so the staged key is handed to each of them
# through its environment. The loader reads the file the load step
# staged and exports ASC_KEY_P8 into this step's shell; the value never
# enters $GITHUB_ENV, never becomes an argument and never reaches a log
# line.
Comment on lines +274 to +279

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug low

Header invariant "the key never enters the step environment" is now false

The documented credential boundary no longer matches the workflow, which can mislead maintainers auditing where the private key lives. Update the header to distinguish $GITHUB_ENV from each client step's shell and state that scripts/stage-asc-key.mjs stages the key for those steps to load.

Prompt for LLM
File .github/workflows/ios-ship.yml, lines 274-279:

Header invariant "the key never enters the step environment" is now false

The workflow's header contract still states "The multiline key never enters the step environment" and that "only that file's path and the two identifiers cross a step boundary", which this change makes false: the key now lives in the environment of the four client steps. The file's own documented invariant is what future maintainers will trust when auditing where the credential lives, and it now contradicts the new loader-based handoff described 250 lines below. Restate the header bullet to say the key never enters the job environment ($GITHUB_ENV) and that the steps starting a client load it into their own shell from the staged file.

Suggested code:

#   * The multiline key never enters the job environment ($GITHUB_ENV).
#     The tooling and scripts/stage-asc-key.mjs run in one step, so the key
#     goes from the fleet's reader straight into a mode-600 file; the steps
#     that start an App Store Connect client load it into their own shell.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

- name: Enable CodeCaps APNs and verify production push profile
run: node "$GITHUB_WORKSPACE/scripts/ios-enable-apns.mjs"
run: |
source scripts/ios-load-asc-key.sh
node "$GITHUB_WORKSPACE/scripts/ios-enable-apns.mjs"

- name: Install App Store profiles
run: bash "$GITHUB_WORKSPACE/scripts/ios-install-appstore-profiles.sh"
run: |
source scripts/ios-load-asc-key.sh
bash "$GITHUB_WORKSPACE/scripts/ios-install-appstore-profiles.sh"
Comment on lines 280 to +288

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Security high

New key-loading steps do not disable xtrace, so the PEM can be traced into the CI log

If xtrace is enabled, the expanded ASC_KEY_P8 assignment can print the unmasked private key into CI logs. Add set +o xtrace before source scripts/ios-load-asc-key.sh in every client step; keep the loader unchanged because it runs in the caller's shell.

Prompt for LLM
File .github/workflows/ios-ship.yml, lines 280-288:

New key-loading steps do not disable xtrace, so the PEM can be traced into the CI log

The three new client steps materialize the raw App Store Connect private key into a shell variable (loader: `ASC_KEY_P8="$pem"; export ASC_KEY_P8`) without first disabling shell tracing, unlike every other credential step in this same workflow (lines 241 and 247 both start with `set +o xtrace`, as do `scripts/ios-stage-asc-key.sh:3` and `scripts/install-release-secrets-tooling.sh:40`). If xtrace is ever on for the step's shell, bash echoes the expanded assignment `export ASC_KEY_P8=-----BEGIN PRIVATE KEY-----…` into the CI log, and because this key comes from Infisical rather than a repository secret the runner will not mask it. Add `set +o xtrace` as the first line of every step that sources the loader (the loader itself must not change shell options, since it is sourced into the caller's shell).

Suggested code:

- name: Enable CodeCaps APNs and verify production push profile
  run: |
    set +o xtrace
    source scripts/ios-load-asc-key.sh
    node "$GITHUB_WORKSPACE/scripts/ios-enable-apns.mjs"

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​


- name: Ship CodeCaps to TestFlight
env:
IOS_TF_RELEASE_NOTES: "1"
IOS_MANUAL_SIGN: "1"
run: bash scripts/ios-ship-testflight.sh
run: |
source scripts/ios-load-asc-key.sh
bash scripts/ios-ship-testflight.sh

- name: Sync standing TestFlight testers
if: success()
Expand All @@ -302,17 +315,12 @@ jobs:
set +e
# The client resolves the signing key from its environment and never
# from a path (Kody rule 42), so the staged key is handed to the
# child through the child's environment by the hardened reader. The
# key stays out of argv, out of stdout, out of $GITHUB_ENV and out
# of the log.
if [ -n "${ASC_KEY_PATH:-}" ]; then
node scripts/ios-fleet/asc-key.mjs exec "$ASC_KEY_PATH" -- \
node scripts/ios-fleet/asc-api.mjs ensure-standing-testers "$appleid"
rc=$?
else
node scripts/ios-fleet/asc-api.mjs ensure-standing-testers "$appleid"
rc=$?
fi
# child through the child's environment by the loader. The key
# stays out of argv, out of stdout, out of $GITHUB_ENV and out of
# the log.
source scripts/ios-load-asc-key.sh
node scripts/ios-fleet/asc-api.mjs ensure-standing-testers "$appleid"
rc=$?
Comment on lines 315 to +323

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug medium

Loader failure in the tester-sync step is swallowed by set +e and misreported

Because set +e is active, a failed source scripts/ios-load-asc-key.sh is ignored, and the generic warning misdirects operators to tester sync. Capture the loader status in load_rc, warn when it is nonzero, and skip node scripts/ios-fleet/asc-api.mjs.

Prompt for LLM
File .github/workflows/ios-ship.yml, lines 315-323:

Loader failure in the tester-sync step is swallowed by set +e and misreported

The standing-testers step sources the loader while `set +e` is in effect (line 315), so a failed credential load neither aborts the step nor is reported as a load failure. The loader's `return 1` is discarded, `asc-api.mjs` then runs with no credential, and the only signal is the generic "tester sync exited N" warning, which points an operator at the tester sync instead of at the key handoff that actually failed. Capture the loader's status separately and warn on it before invoking the client.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

set -e
if [ "$rc" -ne 0 ]; then
echo "::warning title=TestFlight standing testers::tester sync exited ${rc}; see the standing-testers lines above. The TestFlight upload itself succeeded."
Expand Down
82 changes: 82 additions & 0 deletions scripts/ios-load-asc-key.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Load the staged App Store Connect private key into ASC_KEY_P8.
#
# Source this file from a workflow step that starts an App Store Connect
# client:
#
# source scripts/ios-load-asc-key.sh
# node scripts/ios-fleet/asc-api.mjs ensure-standing-testers "$appleid"
#
# The workflow stages the key once, to a mode-600 file, and this helper reads
# that file back into the calling shell as ASC_KEY_P8. The credential then
# travels to the client as the environment variable the client reads, so it
# never enters $GITHUB_ENV, never becomes a command argument and never
# reaches a log line. It exists only in the environment of the step that
# sourced it and in the environment of the clients that step starts.
#
# The clients refuse a path as a credential source (Kody rule 42), so the
# staged file stays what it is: a staging detail for the Apple tools that
# need a key on disk, and the carrier between two steps of the same job.
# Nothing here is a second way for a client to resolve a credential; it is
# the shell layer that owns the credential handing it over.
#
# This file is meant to be sourced, not executed. It sets no shell options,
# because a sourced file that did would change the caller's shell.

# Reads the staged key and exports it as ASC_KEY_P8.
#
# The checks are the ones a credential deserves on the way in: the path must
# be set, the file must exist, its mode must be 600 so that nothing else on
# the runner can read it, and it must hold something. Each failure names
# what is wrong and returns non-zero, and none of them prints the value.
asc_load_asc_key() {
local path="${ASC_KEY_PATH:-}"
if [ -z "$path" ]; then
echo "error: ASC_KEY_PATH is not set; the ASC key was never staged." >&2
return 1
fi
if [ ! -f "$path" ]; then
echo "error: the staged ASC key file is missing." >&2
return 1
fi

# The file holds a credential, so only its owner may read it. The mode is
# the last chance to notice, so a file that is group or world readable is
# refused rather than loaded.
#
# BSD and GNU stat disagree on the flag, and they are told apart by the
# platform rather than tried in turn: GNU stat's -f does not take a
# permissions format, so it prints a filesystem report to stdout and then
# fails, and that report would be captured as the mode and would refuse a
# perfectly good file.
local mode=""
case "$(uname -s)" in
Darwin) mode="$(stat -f '%Lp' "$path" 2>/dev/null || true)" ;;
*) mode="$(stat -c '%a' "$path" 2>/dev/null || true)" ;;
esac
case "$mode" in
600) ;;
'')
echo "error: could not read the mode of the staged ASC key file; refusing to load it." >&2
return 1
;;
*)
echo "error: the staged ASC key file is mode ${mode}, not 600; refusing to load it." >&2
return 1
;;
esac

local pem
pem="$(cat "$path")"
if [ -z "${pem//[[:space:]]/}" ]; then
echo "error: the staged ASC key file holds no key material." >&2
return 1
fi

ASC_KEY_P8="$pem"
export ASC_KEY_P8
unset pem

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules critical

The helper bypasses environment-first ASC secret resolution

Directly reading $path bypasses the approved environment-first credential path, so runtime credentials can diverge from policy. Resolve ASC_KEY_P8 from the environment first, fall back to fleet-secrets get ASC_KEY_P8, and fail fast when neither source yields a value.

Also found in:

  • scripts/test-ios-load-asc-key.sh:47-47
  • scripts/test-ios-load-asc-key.sh:48-48
  • scripts/test-ios-load-asc-key.sh:69-69

Kody rule violation: Load API keys and tokens from approved sources only

Prompt for LLM
File scripts/ios-load-asc-key.sh, line 70:

The helper bypasses environment-first ASC secret resolution

The helper reads the ASC credential from a staged file instead of resolving the process environment first and Infisical through fleet-secrets second. Runtime credentials must follow the approved environment-first secret path. Read ASC_KEY_P8 from the environment when present, otherwise capture it with fleet-secrets get ASC_KEY_P8, and fail fast with the variable name if neither source resolves it.

**Also found in:**
- `scripts/test-ios-load-asc-key.sh:47-47`
- `scripts/test-ios-load-asc-key.sh:48-48`
- `scripts/test-ios-load-asc-key.sh:69-69`

Kody rule violation: [Load API keys and tokens from approved sources only](https://app.kodus.io/settings/code-review/global/kody-rules/543a7760-6c23-4656-b6a4-b151c40a46a6?teamId=69360e3a-6b41-45d2-9e49-fde78fe49457)

Suggested code:

if [ -n "${ASC_KEY_P8:-}" ]; then
  pem="$ASC_KEY_P8"
else
  pem="$(fleet-secrets get ASC_KEY_P8)"
fi
if [ -z "${pem//[[:space:]]/}" ]; then
  echo "error: ASC_KEY_P8 is not set and could not be resolved." >&2
  return 1
fi

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules critical

The helper reads the ASC secret from an arbitrary file

Permitting credentials from an unapproved local source weakens the secret-source boundary. Replace the file read with environment-first resolution of ASC_KEY_P8, then fleet-secrets get ASC_KEY_P8, and fail fast if neither source returns a value.

Also found in:

  • scripts/test-ios-load-asc-key.sh:47-47
  • scripts/test-ios-load-asc-key.sh:48-48
  • scripts/test-ios-load-asc-key.sh:69-69

Kody rule violation: Never mint provider API keys or expose secret values — secret hygiene is binding for every agent

Prompt for LLM
File scripts/ios-load-asc-key.sh, line 70:

The helper reads the ASC secret from an arbitrary file

The helper retrieves the ASC credential directly from an arbitrary local file. Secrets may only come from the process environment, fleet-secrets get, or bootstrap-only Keychain credentials. Resolve ASC_KEY_P8 from the environment first, then use fleet-secrets get ASC_KEY_P8 as the Infisical-backed fallback, and remove the direct file read.

**Also found in:**
- `scripts/test-ios-load-asc-key.sh:47-47`
- `scripts/test-ios-load-asc-key.sh:48-48`
- `scripts/test-ios-load-asc-key.sh:69-69`

Kody rule violation: [Never mint provider API keys or expose secret values — secret hygiene is binding for every agent](https://app.kodus.io/settings/code-review/global/kody-rules/3f722a39-4c80-41d0-b1fb-0151688e9f32?teamId=69360e3a-6b41-45d2-9e49-fde78fe49457)

Suggested code:

if [ -n "${ASC_KEY_P8:-}" ]; then
  pem="$ASC_KEY_P8"
else
  pem="$(fleet-secrets get ASC_KEY_P8)"
fi
if [ -z "${pem//[[:space:]]/}" ]; then
  echo "error: ASC_KEY_P8 is not set and could not be resolved." >&2
  return 1
fi

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

return 0
}

asc_load_asc_key
59 changes: 56 additions & 3 deletions scripts/ios-ship-workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ test("ios-ship.yml ships automatically on a merge that touches the native tree",
// What to Test is mandatory and published, never dry-rendered.
assert.match(code, /IOS_TF_RELEASE_NOTES:\s*"1"/);
// Ship through the wrapper that runs ship-testflight.sh (ensure-tf-ready inside).
assert.match(code, /run: bash scripts\/ios-ship-testflight\.sh\s*$/m);
assert.match(code, /bash scripts\/ios-ship-testflight\.sh\s*$/m);
assert.match(read("scripts/ios-fleet/ship-testflight.sh"), /ensure-tf-ready/);
// Hosted macOS with the stable-Xcode guard, never a self-hosted label.
assert.match(code, /runs-on:\s*macos-latest/);
Expand Down Expand Up @@ -251,6 +251,47 @@ test("ASC credentials come from Fleet-OPS /asc, never the stale repository secre
assert.doesNotMatch(prepare, /echo "\$IOS_DIST_P12/);
});

test("the ASC credential reaches every client through the environment, never a path", () => {
// The clients refuse a path as a credential source (Kody rule 42), so the
// shell layer that owns the credential has to hand it over in the
// environment. Every step that starts a client sources the loader first,
// or the step runs with no credential at all and the failure reads like a
// permission problem.
const clients = [
"ios-enable-apns.mjs",
"ios-install-appstore-profiles.sh",
"ios-ship-testflight.sh",
"asc-api.mjs",
];
const blocks = [...code.matchAll(/^[ ]*run: \|\n((?:[ ]{10,}.*\n|\n)*)/gm)].map((m) => m[1]);
assert.ok(blocks.length >= 6, `the workflow should have several run blocks, found ${blocks.length}`);
for (const client of clients) {
const starters = blocks.filter((b) => b.includes(client));
assert.ok(starters.length >= 1, `no run block starts ${client}`);
for (const block of starters) {
assert.match(
block,
/source scripts\/ios-load-asc-key\.sh/,
`a step that starts ${client} must hand it the credential through the environment`,
);
}
}

// The removed subcommand must not come back: asc-key.mjs has no exec.
assert.doesNotMatch(code, /asc-key\.mjs exec/);
assert.doesNotMatch(read("scripts/ios-fleet/asc-key.mjs"), /"exec"/);

// The loader exports the key and never prints it.
const loader = read("scripts/ios-load-asc-key.sh");
assert.match(loader, /export ASC_KEY_P8/);
assert.doesNotMatch(loader, /echo "\$pem"/);
assert.doesNotMatch(loader, /echo "\$ASC_KEY_P8"/);
assert.doesNotMatch(loader, /printf[^\n]*\$pem/);

// The loader is part of the ship, so a change to it runs the ship.
assert.ok(pushPaths().includes("scripts/ios-load-asc-key.sh"), "editing the loader must re-ship");
});

test("the ship carries no standing gate override and always publishes What to Test", () => {
assert.doesNotMatch(code, /IOS_TF_MIN_INTERVAL_SEC/);
assert.doesNotMatch(code, /--force-ship/);
Expand All @@ -275,8 +316,20 @@ test("the marketing train is 1.0.N and release notes use the owner clock", () =>
});

test("scheduled gate and key handoff shell tests pass", () => {
for (const script of ["scripts/ios-fleet/test-scheduled-ship-gate.sh", "scripts/test-ios-stage-asc-key.sh"]) {
const out = execFileSync("bash", [join(ROOT, script)], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
for (const script of [
"scripts/ios-fleet/test-scheduled-ship-gate.sh",
"scripts/test-ios-stage-asc-key.sh",
"scripts/test-ios-load-asc-key.sh",
]) {
// A shell test that fails says why on stderr, and the reason belongs in
// the failure message: a CI log that only says "Command failed" is a
// dead end.
let out = "";
try {
out = execFileSync("bash", [join(ROOT, script)], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
} catch (error) {
assert.fail(`${script} failed: ${String(error.stderr || "").trim() || error.message}`);
}
assert.match(out, /all tests passed/, `${script} did not report success`);
}
});
126 changes: 126 additions & 0 deletions scripts/test-ios-load-asc-key.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# Offline test for the ASC key handoff in .github/workflows/ios-ship.yml:
# scripts/ios-load-asc-key.sh reads the staged key back into the step's
# environment so the App Store Connect clients receive it as ASC_KEY_P8.
# No Apple credential is used; every key below is a synthetic fixture.
set -euo pipefail

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
mkdir -p "$tmp_dir/staging"
export ASC_STAGING_ROOT="$tmp_dir/staging"
export GITHUB_ENV="$tmp_dir/github-env"
: > "$GITHUB_ENV"

cat > "$tmp_dir/fixture" <<'KEY'
SYNTHETIC-HEADER
SYNTHETIC-MULTILINE-FIXTURE-DO-NOT-USE
SYNTHETIC-FOOTER
KEY

# The loader refuses a path it cannot vouch for, and it says why without ever
# printing the value. Each case is a separate refusal.
refuse() {
local what="$1" needle="$2"
if ( export ASC_KEY_PATH="$3"; source "$repo_root/scripts/ios-load-asc-key.sh" ) \
> "$tmp_dir/out" 2> "$tmp_dir/err"; then
echo "the loader accepted ${what}" >&2
exit 1
fi
if ! grep -Fq "$needle" "$tmp_dir/err"; then
echo "the loader refused ${what} without naming the reason" >&2
cat "$tmp_dir/err" >&2
exit 1
fi
if grep -Fq 'SYNTHETIC-MULTILINE-FIXTURE' "$tmp_dir/out" "$tmp_dir/err"; then
echo 'key material reached the output of a refused load' >&2
exit 1
fi
}

# Stage the key the way the workflow does, so the loader is exercised against
# the file the real handoff produces.
bash "$repo_root/scripts/ios-stage-asc-key.sh" < "$tmp_dir/fixture" > "$tmp_dir/stdout" 2> "$tmp_dir/stderr"
key_path="$(sed -n 's/^ASC_KEY_PATH=//p' "$GITHUB_ENV")"
[[ -f "$key_path" ]]
expected="$(cat "$key_path")"
printf '%s\n' "$expected" > "$tmp_dir/expected"

# The happy path: the loader puts the staged key into the caller's
# environment, where the clients look for it.
(
export ASC_KEY_PATH="$key_path"
source "$repo_root/scripts/ios-load-asc-key.sh" > "$tmp_dir/load-stdout" 2> "$tmp_dir/load-stderr"
[[ "$ASC_KEY_P8" == "$expected" ]]
)
[[ ! -s "$tmp_dir/load-stdout" ]]
[[ ! -s "$tmp_dir/load-stderr" ]]
if grep -Fq 'SYNTHETIC-MULTILINE-FIXTURE' "$tmp_dir/load-stdout" "$tmp_dir/load-stderr"; then
echo 'key material reached the output of a successful load' >&2
exit 1
fi

# The value must reach a child process as an environment variable, because
# that is the only way a client accepts a credential.
(
export ASC_KEY_PATH="$key_path"
source "$repo_root/scripts/ios-load-asc-key.sh" > /dev/null 2>&1
node -e 'process.stdout.write((process.env.ASC_KEY_P8 || "") + "\n")' | cmp - "$tmp_dir/expected"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules critical

The resolved credential is emitted to stdout

Perform the comparison inside Node and return only an exit code, without printing the secret or its digest.

Also found in:

  • scripts/test-ios-load-asc-key.sh:47-47
  • scripts/test-ios-load-asc-key.sh:48-48

Kody rule violation: Never expose secrets or private infrastructure values; reuse existing fleet env vars

Prompt for LLM
File scripts/test-ios-load-asc-key.sh, line 69:

The resolved credential is emitted to stdout

process.stdout.write expands the secret-bearing ASC_KEY_P8 environment variable into output. Output statements must not reveal a resolved secret value. Perform the comparison inside Node and return only a success or failure exit code, without writing the key or its digest.

**Also found in:**
- `scripts/test-ios-load-asc-key.sh:47-47`
- `scripts/test-ios-load-asc-key.sh:48-48`

Kody rule violation: [Never expose secrets or private infrastructure values; reuse existing fleet env vars](https://app.kodus.io/settings/code-review/global/kody-rules/1347af05-4470-44b7-bcdc-4377eeabb35e?teamId=69360e3a-6b41-45d2-9e49-fde78fe49457)

Suggested code:

node -e 'const fs = require("fs"), crypto = require("crypto"); const expected = fs.readFileSync(process.argv[1], "utf8").trim(); const actual = crypto.createHash("sha256").update(process.env.ASC_KEY_P8 || "").digest("hex"); process.exit(actual === expected ? 0 : 1)' "$tmp_dir/expected"

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

)

# The loader reads the file it was pointed at, so a different file yields a
# different value. Without this a loader that returned a constant would pass
# every other case.
cp "$key_path" "$tmp_dir/other.p8"
printf 'SYNTHETIC-OTHER\n' > "$tmp_dir/other.p8"
chmod 600 "$tmp_dir/other.p8"
(
export ASC_KEY_PATH="$tmp_dir/other.p8"
source "$repo_root/scripts/ios-load-asc-key.sh" > /dev/null 2>&1
[[ "$ASC_KEY_P8" == "SYNTHETIC-OTHER" ]]
)

# An unset path, a missing file, a file anyone can read and an empty file are
# all refused.
refuse 'an unset path' 'ASC_KEY_PATH is not set' ''
refuse 'a missing file' 'is missing' "$tmp_dir/no-such-file.p8"
cp "$key_path" "$tmp_dir/loose.p8"
chmod 644 "$tmp_dir/loose.p8"
refuse 'a world-readable key file' 'not 600' "$tmp_dir/loose.p8"
: > "$tmp_dir/empty.p8"
chmod 600 "$tmp_dir/empty.p8"
refuse 'an empty key file' 'no key material' "$tmp_dir/empty.p8"

# The mode probe must be platform aware. GNU stat's -f prints a filesystem
# report to stdout when the format is not one it knows, and that report would
# be captured as the mode, so a perfectly good file would be refused on
# every Linux runner.
if grep -E "stat -f" "$repo_root/scripts/ios-load-asc-key.sh" | grep -qv Darwin; then
echo 'the loader uses the BSD stat outside a Darwin branch' >&2
exit 1
fi

# The loader must not put the key into the job's environment file: that file
# feeds every later step, and the key belongs to one step at a time. The
# check is on the write, not on the word, because the loader explains in a
# comment why it does not write there.
if grep -Eq '>> *"?\$\{?GITHUB_ENV' "$repo_root/scripts/ios-load-asc-key.sh"; then
echo 'the loader writes to the job environment file' >&2
exit 1
fi

# The workflow hands the credential to every client through the environment,
# and it never calls the removed asc-key.mjs exec subcommand.
workflow="$repo_root/.github/workflows/ios-ship.yml"
if grep -Eq 'asc-key\.mjs exec' "$workflow"; then
echo 'the workflow calls the removed asc-key.mjs exec subcommand' >&2
exit 1
fi
load_count="$(grep -c 'source scripts/ios-load-asc-key\.sh' "$workflow" || true)"
if [[ "$load_count" -lt 4 ]]; then
echo "the workflow sources the loader in only ${load_count} steps; every client step needs it" >&2
exit 1
fi

echo 'ios-load-asc-key: all tests passed'
Loading