diff --git a/.github/workflows/autorelease-email.yml b/.github/workflows/autorelease-email.yml index 4ce1175..a9b665d 100644 --- a/.github/workflows/autorelease-email.yml +++ b/.github/workflows/autorelease-email.yml @@ -1,25 +1,47 @@ name: Autorelease email digest -# One deterministic TL;DR email per completed pipeline run. The digest is +# One deterministic TL;DR email per completed pipeline run attempt. The digest is # rendered by `./autorelease/control.py email-digest` from retained run state # only, so a template is selected — never written — at runtime, and no # model-authored prose can reach the outbound channel. +# +# Each run reaches this workflow by exactly one route. The watcher is started by +# its schedule or by a person, so its completion fires `workflow_run`. The publish +# transaction is dispatched by the watcher with GITHUB_TOKEN, and GitHub starts no +# `workflow_run` for runs that token started, so the publish workflow calls this +# one from its own last job instead and passes the conclusion its jobs reached. on: workflow_run: workflows: - PHP autorelease watcher - - Autorelease publish transaction types: - completed + workflow_call: + inputs: + run_id: + description: Run whose retained state the digest describes + required: true + type: string + run_attempt: + description: Attempt of that run + required: true + type: string + workflow: + description: Calling pipeline workflow; only publish calls this one + required: true + type: string + conclusion: + description: Conclusion the calling run's jobs reached + required: true + type: string + secrets: + RESEND_API_KEY: + required: false permissions: contents: read actions: read -concurrency: - group: autorelease-email-${{ github.event.workflow_run.id }} - cancel-in-progress: false - defaults: run: shell: bash @@ -29,21 +51,29 @@ jobs: name: Send run digest runs-on: ubuntu-latest timeout-minutes: 10 + # Inside a call, `github.event` is the caller's dispatch event, so each value + # comes from the inputs when called and from the completed run otherwise. + concurrency: + group: autorelease-email-${{ inputs.run_id || github.event.workflow_run.id }} + cancel-in-progress: false env: - RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} EMAIL_FROM: ${{ vars.AUTORELEASE_EMAIL_FROM }} EMAIL_TO: ${{ vars.AUTORELEASE_EMAIL_TO }} - RUN_ID: ${{ github.event.workflow_run.id }} - RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} - RUN_URL: ${{ github.event.workflow_run.html_url }} + CALLED_WORKFLOW: ${{ inputs.workflow }} + RUN_ID: ${{ inputs.run_id || github.event.workflow_run.id }} + RUN_ATTEMPT: ${{ inputs.run_attempt || github.event.workflow_run.run_attempt }} + RUN_URL: ${{ inputs.run_id && format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, inputs.run_id) || github.event.workflow_run.html_url }} RUN_NAME: ${{ github.event.workflow_run.name }} - RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + RUN_CONCLUSION: ${{ inputs.conclusion || github.event.workflow_run.conclusion }} GH_TOKEN: ${{ github.token }} steps: - name: Decide whether delivery is configured id: gate # An unconfigured repository skips quietly instead of failing, so the # digest can merge ahead of the Resend secret and variables existing. + # The secret is only in this step and the send step, never in the job. + env: + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} run: | if [[ -n "$RESEND_API_KEY" && -n "$EMAIL_FROM" && -n "$EMAIL_TO" ]]; then echo "configured=true" >> "$GITHUB_OUTPUT" @@ -60,13 +90,26 @@ jobs: if: steps.gate.outputs.configured == 'true' run: | mkdir -p email-run/state + [[ "$RUN_ID" =~ ^[1-9][0-9]*$ ]] [[ "$RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]] - case "$RUN_NAME" in - "PHP autorelease watcher") + if [[ -n "$CALLED_WORKFLOW" ]]; then + if [[ "$CALLED_WORKFLOW" != publish ]]; then + echo "unrouted calling workflow: $CALLED_WORKFLOW" >&2 + exit 1 + fi + workflow=publish + elif [[ "$RUN_NAME" == "PHP autorelease watcher" ]]; then + workflow=watcher + else + echo "unrouted triggering workflow: $RUN_NAME" >&2 + exit 1 + fi + case "$workflow" in + watcher) echo "workflow=watcher" >> "$GITHUB_OUTPUT" artifacts=("autorelease-investigation-$RUN_ID") ;; - "Autorelease publish transaction") + publish) echo "workflow=publish" >> "$GITHUB_OUTPUT" # Each attempt retains its own state, and a rerun of only the failed # jobs keeps the state an earlier attempt retained, so the newest @@ -76,10 +119,6 @@ jobs: artifacts+=("release-transaction-state-$RUN_ID-$attempt") done ;; - *) - echo "unrouted triggering workflow: $RUN_NAME" >&2 - exit 1 - ;; esac # A run that crashed before retaining its state has no artifact; the # renderer then only accepts that absence for the failure templates. @@ -106,6 +145,14 @@ jobs: - name: Send the digest through Resend if: steps.gate.outputs.configured == 'true' # The secret reaches exactly one place: the Authorization header. + # Transient failures are retried under one idempotency key per run attempt, so + # a retry after a lost reply cannot send the digest twice. A delivery that still + # fails fails this job, and with it the run: the digest is the report, so a red + # run is the only one left when the channel is down, and rerunning the failed + # jobs of a publish run whose other jobs passed repeats only this one. + env: + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + WORKFLOW: ${{ steps.state.outputs.workflow }} run: | jq \ --arg from "$EMAIL_FROM" \ @@ -114,7 +161,9 @@ jobs: email-run/digest.json > email-run/payload.json curl --fail-with-body --silent --show-error \ --connect-timeout 10 --max-time 30 \ + --retry 3 --retry-delay 15 --retry-connrefused \ --request POST https://api.resend.com/emails \ + --header "Idempotency-Key: php-bin-$WORKFLOW-$RUN_ID-$RUN_ATTEMPT" \ --header "Authorization: Bearer $RESEND_API_KEY" \ --header "Content-Type: application/json" \ --data @email-run/payload.json diff --git a/.github/workflows/autorelease-implement.yml b/.github/workflows/autorelease-implement.yml index a58c229..3b23292 100644 --- a/.github/workflows/autorelease-implement.yml +++ b/.github/workflows/autorelease-implement.yml @@ -300,14 +300,17 @@ jobs: # from the earlier attempt, so the automation branch is replaced under a lease # and any open PR on it is reused; the exact-SHA gates below still bind the # merge to this run's validated commit. + # The fetch is forced so the tracking ref, and with it the lease, is exactly the + # branch head GitHub reports now, whatever an earlier attempt left behind. if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then - git fetch origin "$branch:refs/remotes/origin/$branch" + git fetch origin "+refs/heads/$branch:refs/remotes/origin/$branch" fi git push --force-with-lease origin "HEAD:refs/heads/$branch" existing="$(gh pr list --head "$branch" --state open --json number --jq '.[0].number // empty')" if [[ -z "$existing" ]]; then - url="$(gh pr create --base main --head "$branch" --title "chore: $action_key" \ - --body "Deterministically sealed autorelease patch for \`$action_key\`.\n\nValidated commit: \`$(git rev-parse HEAD)\`.")" + body="$(printf "Deterministically sealed autorelease patch for \`%s\`.\n\nValidated commit: \`%s\`." \ + "$action_key" "$(git rev-parse HEAD)")" + url="$(gh pr create --base main --head "$branch" --title "chore: $action_key" --body "$body")" existing="${url##*/}" fi echo "number=$existing" >> "$GITHUB_OUTPUT" @@ -426,7 +429,8 @@ jobs: --head "${{ steps.readiness.outputs.head_sha }}" \ --record "${{ steps.readiness.outputs.record }}" \ --digest "${{ steps.readiness.outputs.digest }}" \ - --checks-output autorelease-run/readiness-checks.json + --checks-output autorelease-run/readiness-checks.json \ + --require-protected-controls # Any failed phase stops here with one deduplicated owner issue on the action key. # A new branch whose build fails the exact module comparison carries that module diff --git a/.github/workflows/autorelease-publish.yml b/.github/workflows/autorelease-publish.yml index f8c7335..a7f3051 100644 --- a/.github/workflows/autorelease-publish.yml +++ b/.github/workflows/autorelease-publish.yml @@ -749,25 +749,53 @@ jobs: jq --arg version "$VERSION" \ '.severity="info" | .summary="PHP \($version) was published and verified through fresh exact and branch-shorthand mise installs." | .finalResult="passed"' \ release-run/event.json > release-run/notification-event.json + # A rerun of this job must neither file a second record nor trip over what an + # earlier attempt of this run left behind. A record already on main that completes + # exactly this release (merged by an earlier attempt, or recovered by the watcher) + # ends the step. Otherwise the run-scoped branch belongs to this run alone, so any + # PR or branch an earlier attempt left on it is withdrawn and filed afresh. - name: Commit final event record through a checked PR id: event_pr env: GH_TOKEN: ${{ github.token }} + BRANCH: autorelease/event-${{ github.run_id }} run: | git fetch origin main - git checkout -B "autorelease/event-${{ github.run_id }}" origin/main - base="$(git rev-parse HEAD)" filename="$(./autorelease/control.py action-filename "$ACTION_KEY")" - cp release-run/event.json "autorelease-events/$filename" - git add "autorelease-events/$filename" + record="autorelease-events/$filename" + rm -f release-run/main-record.json + if git cat-file -e "origin/main:$record" 2>/dev/null; then + git show "origin/main:$record" > release-run/main-record.json + fi + recorded="$(./autorelease/control.py release-recorded \ + --record release-run/main-record.json \ + --action-key "$ACTION_KEY" \ + --version "$VERSION" \ + --transaction release-run/transaction.json)" + if [[ "$recorded" == "true" ]]; then + echo "The event record for $ACTION_KEY is already on main." + echo "already_recorded=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "already_recorded=false" >> "$GITHUB_OUTPUT" + gh auth setup-git + for stale in $(gh pr list --repo "${{ github.repository }}" --head "$BRANCH" --state open \ + --json number --jq '.[].number'); do + gh pr close "$stale" --repo "${{ github.repository }}" --delete-branch + done + if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null; then + git push origin --delete "$BRANCH" + fi + git checkout -B "$BRANCH" origin/main + base="$(git rev-parse HEAD)" + cp release-run/event.json "$record" + git add "$record" git -c user.name=autorelease -c user.email=autorelease@invalid \ commit -m "chore: complete $ACTION_KEY" head="$(git rev-parse HEAD)" - record="autorelease-events/$filename" digest="sha256:$(shasum -a 256 "$record" | awk '{print $1}')" - gh auth setup-git git push origin HEAD - url="$(gh pr create --base main --head "autorelease/event-${{ github.run_id }}" \ + url="$(gh pr create --base main --head "$BRANCH" \ --title "chore: complete $ACTION_KEY" \ --body "Durable event record for the immutable verified release transaction.")" { @@ -779,6 +807,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Validate and merge final event record id: merge + if: steps.event_pr.outputs.already_recorded == 'false' env: GH_TOKEN: ${{ github.token }} run: | @@ -788,7 +817,8 @@ jobs: --head "${{ steps.event_pr.outputs.head_sha }}" \ --record "${{ steps.event_pr.outputs.record }}" \ --digest "${{ steps.event_pr.outputs.digest }}" \ - --checks-output release-run/event-checks.json + --checks-output release-run/event-checks.json \ + --require-protected-controls - name: Notify owner of completed release env: GH_TOKEN: ${{ github.token }} @@ -801,16 +831,30 @@ jobs: --repo "${{ github.repository }}" \ --owner "${{ vars.AUTORELEASE_OWNER }}" # This job only runs after publication, so the release is live either way; the - # state now also says whether its event record reached main. + # state now also says whether its event record is on main. Main is the authority: + # a failed step says nothing about a record an earlier attempt already merged, or + # a merge whose reply was lost, so without a merge in this attempt main is read. - name: Record whether the event record merged if: always() env: MERGE_OUTCOME: ${{ steps.merge.outcome }} + ALREADY_RECORDED: ${{ steps.event_pr.outputs.already_recorded }} run: | mkdir -p release-run recorded=false - if [[ "$MERGE_OUTCOME" == "success" ]]; then + if [[ "$MERGE_OUTCOME" == "success" || "$ALREADY_RECORDED" == "true" ]]; then recorded=true + elif filename="$(./autorelease/control.py action-filename "$ACTION_KEY")" \ + && git fetch origin main \ + && git show "origin/main:autorelease-events/$filename" > "$RUNNER_TEMP/main-record.json"; then + transaction=() + if [[ -f release-run/transaction.json ]]; then + transaction=(--transaction release-run/transaction.json) + fi + if [[ "$(./autorelease/control.py release-recorded --record "$RUNNER_TEMP/main-record.json" \ + --action-key "$ACTION_KEY" --version "$VERSION" "${transaction[@]}")" == "true" ]]; then + recorded=true + fi fi jq -n --argjson recorded "$recorded" --arg version "$VERSION" \ '{schemaVersion:1,released:true,recorded:$recorded,version:$version}' > release-run/transaction-state.json @@ -919,3 +963,25 @@ jobs: --backend github \ --repo "${{ github.repository }}" \ --owner "${{ vars.AUTORELEASE_OWNER }}" + + # GitHub starts no `workflow_run` for a run GITHUB_TOKEN dispatched, and the watcher + # dispatches this one that way, so the run emails its own digest from this last job. + # It waits for every other job and passes the conclusion they reached: failure when + # any failed, else cancelled when any was cancelled, else success. A rerun of failed + # jobs reruns this one too, so each attempt sends one digest, as a completed + # `workflow_run` would. + email: + name: Email the run digest + needs: [preflight, build, release, verify-draft, publish, verify-public, finalize, notify-failure] + if: always() + permissions: + actions: read + contents: read + uses: ./.github/workflows/autorelease-email.yml + with: + run_id: ${{ github.run_id }} + run_attempt: ${{ github.run_attempt }} + workflow: publish + conclusion: ${{ contains(needs.*.result, 'failure') && 'failure' || contains(needs.*.result, 'cancelled') && 'cancelled' || 'success' }} + secrets: + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} diff --git a/.github/workflows/autorelease-watch.yml b/.github/workflows/autorelease-watch.yml index 037cea4..41f11cc 100644 --- a/.github/workflows/autorelease-watch.yml +++ b/.github/workflows/autorelease-watch.yml @@ -427,8 +427,9 @@ jobs: head="$(git rev-parse HEAD)" record_digest="sha256:$(shasum -a 256 autorelease-state/last-evidence.json | awk '{print $1}')" gh auth setup-git + # Forced, so the lease below is exactly the branch head GitHub reports now. if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then - git fetch origin "$branch:refs/remotes/origin/$branch" + git fetch origin "+refs/heads/$branch:refs/remotes/origin/$branch" fi git push --force-with-lease origin "HEAD:refs/heads/$branch" number="$(gh pr list --state open --head "$branch" --json number --jq '.[0].number // empty')" diff --git a/AUTORELEASE.md b/AUTORELEASE.md index d2240e9..f2f4112 100644 --- a/AUTORELEASE.md +++ b/AUTORELEASE.md @@ -159,7 +159,11 @@ only after the one before succeeded: handoff without repeating that job's recapture. Evidence that moved stops the run with the verified draft left in place, and the next admitted dispatch reuses that draft. It then re-reads the draft - once more, records `publishing`, and only then makes the release public. A + once more, records `publishing`, and only then makes the release public. The + publication sets GitHub's "Latest" flag explicitly: on only when no published + release sorts above this version, comparing major, minor, patch, and revision + as numbers, so a rebuild of an older branch never takes the badge from the + newest version. A run that stops between the publication and its record is therefore still known to be possibly live, and reports a warning rather than a failed release; so does a rerun that stops early after an earlier attempt already @@ -169,14 +173,24 @@ only after the one before succeeded: 4. `verify-public`, read-only like `verify-draft`, runs fresh public exact-version and branch-shorthand installs. 5. `finalize` completes the durable event record through an exact-SHA pull - request. + request. Rerunning it is safe: a record already on main that completes + exactly this release, merged by an earlier attempt or recovered by the + watcher, ends the job without a second record, and a pull request or branch + an earlier attempt left on the run's own `autorelease/event-` branch + is withdrawn before the record is filed afresh. A complete record on main + that names another release stops the job. The rerun can only file the + record while main is still the commit the run was dispatched at, because + `Protected controls` binds a publish run's record to exactly that commit; + once main has moved on, the watcher's record recovery is the path. Both install jobs pass their read-only token to `mise-php`, whose GitHub API reads would otherwise be rate limited, and restore no mise cache. Every artifact a job retains is named per run attempt, so rerunning a failed job never collides with what an earlier attempt kept, and the transaction state the failure notification and the email digest read is taken from the newest -attempt that retained one. +attempt that retained one. That state records whether the event record is on +main by reading main itself whenever the attempt did not merge it, so a +failed rerun after an earlier merge still reports the record as complete. Validation deliberately runs the repository's own scripts at the sealed commit: `autorelease-implement.yml`, and `autorelease-consumer.yml` in @@ -197,15 +211,24 @@ action, or final-result change adds a comment. Critical failures stop mutation. GitHub Actions failure email is an independent fallback. `Autorelease email digest` additionally sends one fixed-template TL;DR email -after every completed watcher or publish run, including quiet healthy days, so -silence stops being ambiguous between "no change" and "the schedule stopped". +after every completed watcher or publish run attempt, including quiet healthy +days, so silence stops being ambiguous between "no change" and "the schedule +stopped". A watcher run reaches it through `workflow_run`. The watcher +dispatches the publish transaction with `GITHUB_TOKEN`, for which GitHub +starts no `workflow_run`, so the publish run calls the digest workflow from its +own last job instead, with the conclusion its other jobs reached; publish is +not a `workflow_run` trigger, so no run is emailed twice. The template is selected by `email-digest` in `autorelease/control.py` from retained run state alone and delivered through Resend; no free-form plan prose reaches the outbound channel, and the workflow skips quietly until the `RESEND_API_KEY` secret and the email variables exist. Run state that matches no template — including a corrupt retained artifact — still sends a fallback summary naming the exact rejection reason, so the channel cannot go silent on -precisely the runs that need a look. +precisely the runs that need a look. Delivery retries transient Resend errors +under one idempotency key per run attempt; a delivery that still fails fails +the digest job, and with it a publish run, because a red run is the only +report left when the channel is down. Rerunning that run's failed jobs sends +the digest again without repeating any job that passed. There is no repair phase. A failed classification input, admission, sealing, validation, build, or merge stops that run and raises the deduplicated owner diff --git a/autorelease/_state.py b/autorelease/_state.py index 023fd2f..93d309b 100644 --- a/autorelease/_state.py +++ b/autorelease/_state.py @@ -99,6 +99,51 @@ def validate_completed_event_record(record: dict[str, Any]) -> None: require(current == record["state"], "autorelease event state does not match its history") +# Evidence kinds that name the published release a completed record describes: the +# publish transaction's own record, and the watcher's recovery of a missing one. +RELEASE_RECORD_EVIDENCE_KINDS = {"published_release", "published_immutable_release"} + + +def release_event_recorded( + record: dict[str, Any] | None, + action_key: str, + version: str, + asset_digests: dict[str, str] | None = None, +) -> bool: + """Return whether `record`, main's copy of an event record, completes this release. + + A rerun of the publish run's final job must not file a second record, and must not + report a record as missing that an earlier attempt, or the watcher's recovery, + already merged. No record, or one still short of `complete` (a new branch's record + waiting for its release), means this release is not recorded yet. A complete record + counts only when it is a valid completed history for exactly this action key whose + release evidence names this version and, when the transaction is known, exactly its + asset digests. A complete record naming anything else contradicts the release and + is rejected rather than read as either answer. + """ + require(bool(STABLE_VERSION_RE.fullmatch(version or "")), f"release version is invalid: {version}") + if record is None: + return False + require(isinstance(record, dict), "autorelease event must be an object") + if record.get("state") != "complete": + return False + validate_completed_event_record(record) + require(record.get("actionKey") == action_key, "the completed record belongs to another action key") + named = [ + item + for transition in record["history"] + for item in transition["evidence"] + if item.get("kind") in RELEASE_RECORD_EVIDENCE_KINDS and item.get("version") == version + ] + require(bool(named), f"the completed record for {action_key} does not name release {version}") + if asset_digests is not None: + require( + all(item.get("assetDigests") == asset_digests for item in named), + f"the completed record for {action_key} names other assets than release {version}", + ) + return True + + def transition_event(event: dict[str, Any], target: str, evidence: list[dict[str, Any]]) -> dict[str, Any]: current = event.get("state", "detected") require(target in LEGAL_EVENT_TRANSITIONS.get(current, set()), f"illegal event transition: {current} -> {target}") @@ -476,6 +521,34 @@ def unrecorded_published_release( return min(keys, default=None) +def release_is_newest(version: str, releases: Iterable[dict[str, Any]]) -> bool: + """Return whether `version` sorts above every other published release. + + GitHub moves the "Latest" badge to whichever release was published last unless the + publication says otherwise, so a rebuild of an older branch would take it from the + newest PHP version. Versions compare numerically as (major, minor, patch, revision), + a plain patch counting as revision 0, so `8.5.11-2` sorts above `8.5.11` and + `8.10.0` above `8.9.9`. Drafts, prereleases, tags that are not release versions, + and `version` itself are ignored; with nothing else published the version is newest. + """ + tag = PUBLISHED_RELEASE_TAG_RE.fullmatch(version) + require(tag is not None, f"release version is not a PHP version: {version}") + + def order(match: re.Match[str]) -> tuple[int, ...]: + return (*(int(part) for part in match.group(1).split(".")), int(match.group(3) or 0)) + + candidate = order(tag) + for release in releases: + if not isinstance(release, dict) or release.get("draft") or release.get("prerelease"): + continue + other = PUBLISHED_RELEASE_TAG_RE.fullmatch(str(release.get("tag_name", ""))) + if other is None or other.group(0) == version: + continue + if order(other) > candidate: + return False + return True + + def recipe_identity_note(identity: str) -> str: """Return the release-notes line that records the recipe a release was built from.""" require(bool(SHA256_RE.fullmatch(identity or "")), "recipe identity is not a sha256 digest") diff --git a/autorelease/_validation.py b/autorelease/_validation.py index 9dbf477..f31649e 100644 --- a/autorelease/_validation.py +++ b/autorelease/_validation.py @@ -25,8 +25,7 @@ r"^(no_change:[0-9a-f]{16}|new_patch:\d+\.\d+\.\d+|new_branch:\d+\.\d+|" r"branch_eol:\d+\.\d+:\d{4}-\d{2}-\d{2}|" r"recipe_rebuild:\d+\.\d+\.\d+:[1-9]\d*|" - r"repair:\d+\.\d+\.\d+:[0-9a-f]{8,64}|" - r"(?:source_unhealthy|health_failed|policy_failure|auth_failure):[0-9a-f]{8,64})$" + r"(?:source_unhealthy|health_failed|policy_failure):[0-9a-f]{8,64})$" ) STABLE_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[1-9]\d*)?$") PROTECTED_PATHS = pathlib.Path(__file__).with_name("protected-paths.json") diff --git a/autorelease/control.py b/autorelease/control.py index aeb445d..63d3580 100755 --- a/autorelease/control.py +++ b/autorelease/control.py @@ -107,6 +107,8 @@ notification_decision, pending_recipe_rebuild, recipe_identity_note, + release_event_recorded, + release_is_newest, release_recipe_identity, release_transition, retained_notification_issue, @@ -352,6 +354,14 @@ def main(argv: list[str] | None = None) -> int: operator_parser.add_argument("--operator-file", required=True, type=pathlib.Path) operator_parser.add_argument("--require-enabled", action="store_true") + # Whether main's copy of an event record already completes one published release; + # an absent --record file means main has no record under that name. + recorded_parser = subparsers.add_parser("release-recorded") + recorded_parser.add_argument("--record", required=True, type=pathlib.Path) + recorded_parser.add_argument("--action-key", required=True) + recorded_parser.add_argument("--version", required=True) + recorded_parser.add_argument("--transaction", type=pathlib.Path) + filename_parser = subparsers.add_parser("action-filename") filename_parser.add_argument("action_key") filename_parser.add_argument("--suffix", default=".json") @@ -444,6 +454,18 @@ def main(argv: list[str] | None = None) -> int: allowed = mutation_allowed(state) require(allowed or not args.require_enabled, "unattended mutation is paused") print("enabled" if allowed else "paused") + elif args.command == "release-recorded": + asset_digests = None + if args.transaction is not None: + transaction = load_json(args.transaction) + asset_digests = transaction.get("assetDigests") if isinstance(transaction, dict) else None + require( + isinstance(asset_digests, dict) and bool(asset_digests), + "release transaction carries no asset digests", + ) + record = load_json(args.record) if args.record.exists() else None + recorded = release_event_recorded(record, args.action_key, args.version, asset_digests) + print("true" if recorded else "false") elif args.command == "action-filename": print(action_filename(args.action_key, args.suffix)) elif args.command == "validate-archive": diff --git a/autorelease/verify.py b/autorelease/verify.py index 370e35f..ae2d192 100755 --- a/autorelease/verify.py +++ b/autorelease/verify.py @@ -527,11 +527,9 @@ def a09(self, directory: pathlib.Path) -> list[str]: "new_branch:8.6", "branch_eol:8.2:2026-12-31", "recipe_rebuild:8.5.9:2", - "repair:8.5.9:deadbeef", "source_unhealthy:deadbeef", "health_failed:deadbeef", "policy_failure:deadbeef", - "auth_failure:deadbeef", ] for action_key in action_keys: mise_name = run( @@ -829,7 +827,7 @@ def a19(self, directory: pathlib.Path) -> list[str]: "auditEvidence": [{"path": "evidence.json", "digest": sha256_file(evidence)}], } complete = audit_reconstruction(event, directory) - blocked = audit_reconstruction({**event, "actionKey": "repair:8.5.9:deadbeef", "state": "blocked"}, directory) + blocked = audit_reconstruction({**event, "actionKey": "policy_failure:deadbeef", "state": "blocked"}, directory) (directory / "audit.json").write_bytes(canonical_json({"complete": complete, "blocked": blocked})) return ["audit.json", "evidence.json"] diff --git a/docs/autorelease-admin-evidence.json b/docs/autorelease-admin-evidence.json index defd4bb..758082c 100644 --- a/docs/autorelease-admin-evidence.json +++ b/docs/autorelease-admin-evidence.json @@ -24,7 +24,7 @@ }, "afterSnapshot": { "path": "../mise-php/docs/admin-state/mise-php-after.json", - "digest": "sha256:e50672433148e1054cd0436af11f47cbc7bf643db650eaed166ffcd1633fa632" + "digest": "sha256:da186692248707dc17c433cfe7abbaca73586dfa616dc3149083dd9d6268927c" }, "settingsPullRequests": [ "https://github.com/Bigpixelrocket/mise-php/pull/9", diff --git a/schemas/autorelease-plan.schema.json b/schemas/autorelease-plan.schema.json index 3ccb004..1d7545e 100644 --- a/schemas/autorelease-plan.schema.json +++ b/schemas/autorelease-plan.schema.json @@ -8,7 +8,7 @@ "schemaVersion": {"type": "integer", "const": 1}, "actionKey": { "type": "string", - "pattern": "^(no_change:[0-9a-f]{16}|new_patch:\\d+\\.\\d+\\.\\d+|new_branch:\\d+\\.\\d+|branch_eol:\\d+\\.\\d+:\\d{4}-\\d{2}-\\d{2}|recipe_rebuild:\\d+\\.\\d+\\.\\d+:[1-9]\\d*|repair:\\d+\\.\\d+\\.\\d+:[0-9a-f]{8,64}|(?:source_unhealthy|health_failed|policy_failure|auth_failure):[0-9a-f]{8,64})$" + "pattern": "^(no_change:[0-9a-f]{16}|new_patch:\\d+\\.\\d+\\.\\d+|new_branch:\\d+\\.\\d+|branch_eol:\\d+\\.\\d+:\\d{4}-\\d{2}-\\d{2}|recipe_rebuild:\\d+\\.\\d+\\.\\d+:[1-9]\\d*|(?:source_unhealthy|health_failed|policy_failure):[0-9a-f]{8,64})$" }, "action": {"type": "string", "enum": ["no_change", "new_patch", "new_branch", "branch_eol", "recipe_rebuild", "blocked", "needs_human"]}, "evidence": { diff --git a/scripts/publish-release b/scripts/publish-release index e313dfc..553f93d 100755 --- a/scripts/publish-release +++ b/scripts/publish-release @@ -17,6 +17,7 @@ from autorelease.control import ( # noqa: E402 load_json, recipe_identity, recipe_identity_note, + release_is_newest, release_recipe_identity, release_transition, sha256_file, @@ -164,9 +165,26 @@ def github_effect( if sha256_file(pathlib.Path(temporary) / name) != digest: raise ControlError(f"downloaded release asset differs: {name}") elif target == "published": - release = json.loads(gh("release", "view", version, "--repo", repo, "--json", "isDraft")) + release = json.loads(gh("release", "view", version, "--repo", repo, "--json", "isDraft,databaseId")) if release["isDraft"]: - gh("release", "edit", version, "--repo", repo, "--draft=false", capture=False) + # Left to GitHub, the "Latest" badge follows the last publication, so a + # rebuild of an older branch would take it from the newest version. The + # publication therefore states it: latest only when no published release + # sorts above this version. + pages = json.loads(gh("api", f"repos/{repo}/releases?per_page=100", "--paginate", "--slurp")) + published = [item for page in pages for item in page] + latest = "true" if release_is_newest(version, published) else "false" + gh( + "api", + "--method", + "PATCH", + f"repos/{repo}/releases/{int(release['databaseId'])}", + "-F", + "draft=false", + "-f", + f"make_latest={latest}", + capture=False, + ) def main() -> int: diff --git a/scripts/test.sh b/scripts/test.sh index 1f983d7..4a7cbef 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -178,16 +178,27 @@ mkdir -p "$SCRATCH_DIR/failing-install" cat > "$SCRATCH_DIR/failing-install/install" <<'SH' #!/usr/bin/env bash for argument in "$@"; do - [[ "$argument" == */include/sodium/export.h ]] && exit 1 + if [[ "$argument" == */include/sodium/export.h ]]; then + echo "install: fixture refused to copy $argument" >&2 + exit 1 + fi done exec /usr/bin/install "$@" SH chmod +x "$SCRATCH_DIR/failing-install/install" if PATH="$SCRATCH_DIR/failing-install:$PATH" \ - "$SCRIPT_DIR/package.sh" "$FIXTURE_ROOT/bin/php" 8.4.99 2>/dev/null; then + "$SCRIPT_DIR/package.sh" "$FIXTURE_ROOT/bin/php" 8.4.99 \ + > "$SCRATCH_DIR/failing-install.out" 2> "$SCRATCH_DIR/failing-install.log"; then echo "Expected packaging to reject a library header it could not copy." >&2 exit 1 fi +# The refused copy is what stopped packaging, before any archive was written. +grep -Fq "install: fixture refused to copy $FIXTURE_ROOT/include/sodium/export.h" \ + "$SCRATCH_DIR/failing-install.log" +if grep -F 'Created' "$SCRATCH_DIR/failing-install.out"; then + echo "Packaging wrote an archive although a library header failed to copy." >&2 + exit 1 +fi # So does an empty sodium header folder. mv "$FIXTURE_ROOT/include/sodium" "$SCRATCH_DIR/sodium" diff --git a/tests/test_autorelease.py b/tests/test_autorelease.py index f6b31d5..1b633a1 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -32,6 +32,8 @@ pending_recipe_rebuild, recipe_identity, recipe_identity_note, + release_event_recorded, + release_is_newest, release_recipe_identity, validate_recipe_rebuild_evidence, email_digest, @@ -673,6 +675,62 @@ def run(argv, **_kwargs): # A published release is immutable and is never edited. self.assertEqual([], effect({"isDraft": False, "body": "Autorelease publication."})) + def test_release_is_newest_compares_versions_numerically_with_revisions(self): + published = [self._published(tag) for tag in ("8.5.11", "8.5.11-2", "8.4.26-1", "8.2.32-2")] + # A rebuild of the newest version sorts above its plain patch and earlier revisions. + self.assertTrue(release_is_newest("8.5.11-3", published)) + # Any older version, including a newer revision of an older branch, is not newest. + for version in ("8.2.32-3", "8.4.26-2", "8.5.10-3", "8.5.11-1", "8.5.11"): + self.assertFalse(release_is_newest(version, published), version) + # Components compare as numbers, never as text. + self.assertTrue(release_is_newest("8.10.0", [self._published("8.9.9-4")])) + self.assertFalse(release_is_newest("8.9.9-4", [self._published("8.10.0")])) + self.assertTrue(release_is_newest("8.5.12", [self._published("8.5.9-9")])) + # Drafts, prereleases, unrelated tags, and the version itself never outrank it. + ignored = [ + self._published("9.0.0", draft=True), + self._published("9.0.1", prerelease=True), + self._published("v99"), + self._published("8.5.11-3"), + "not a release", + ] + self.assertTrue(release_is_newest("8.5.11-3", ignored)) + self.assertTrue(release_is_newest("8.5.11-3", [])) + with self.assertRaises(ControlError): + release_is_newest("latest", published) + + def test_publisher_states_whether_the_publication_is_latest(self): + namespace = runpy.run_path( + str(pathlib.Path(__file__).resolve().parents[1] / "scripts/publish-release"), + run_name="publish_release_fixture", + ) + github_effect = namespace["github_effect"] + pages = json.dumps([[self._published("8.5.11-2"), self._published("8.4.26-1")], [self._published("8.2.32-2")]]) + + def publish(version, is_draft=True): + calls = [] + + def gh(*arguments, capture=True): + calls.append(arguments) + if arguments[:2] == ("release", "view"): + return json.dumps({"isDraft": is_draft, "databaseId": 42}) + if arguments[:2] == ("api", "repos/o/r/releases?per_page=100"): + return pages + return "" + + with mock.patch.dict(github_effect.__globals__, {"gh": gh}): + github_effect("published", "o/r", version, "c" * 40, pathlib.Path("assets"), {}) + return [call for call in calls if "PATCH" in call] + + for version, latest in (("8.5.11-3", "true"), ("8.2.32-3", "false"), ("8.4.27", "false"), ("8.6.0", "true")): + self.assertEqual( + [("api", "--method", "PATCH", "repos/o/r/releases/42", "-F", "draft=false", "-f", f"make_latest={latest}")], + publish(version), + version, + ) + # A release that is already public is immutable here, and its badge is left alone. + self.assertEqual([], publish("8.5.11-3", is_draft=False)) + def test_rebuild_selection_is_deterministic_and_covers_every_published_version(self): current = "sha256:" + "c" * 64 # The releases published before recipe identities existed record none. @@ -905,6 +963,24 @@ def test_plan_shape_is_exact_and_only_lifecycle_plans_edit(self): full_plan(action="new_patch", actionKey="new_branch:8.6"), manifest_path, set() ) + def test_admin_evidence_names_each_snapshot_by_its_own_digest(self): + evidence = json.loads((ROOT / "docs/autorelease-admin-evidence.json").read_text()) + snapshots = [ + entry + for repository in evidence["repositories"].values() + for entry in (repository["beforeSnapshot"], repository["afterSnapshot"]) + ] + self.assertEqual(4, len(snapshots)) + for entry in snapshots: + self.assertRegex(entry["digest"], r"^sha256:[0-9a-f]{64}$", entry["path"]) + # mise-php's snapshots live in that repository; this one can check its own. + if entry["path"].startswith("../"): + continue + snapshot = json.loads((ROOT / entry["path"]).read_text()) + recorded = snapshot.pop("snapshotDigest") + self.assertEqual(recorded, sha256_bytes(canonical_json(snapshot)), entry["path"]) + self.assertEqual(recorded, entry["digest"], entry["path"]) + def test_plan_schema_matches_admission(self): schema = json.loads((ROOT / "schemas/autorelease-plan.schema.json").read_text()) self.assertEqual(set(schema["required"]), set(schema["properties"])) @@ -1363,6 +1439,13 @@ def test_future_branch_action_keys_admitted(self): ): self.assertIsNotNone(ACTION_KEY_RE.fullmatch(key), key) + def test_retired_action_key_families_are_rejected(self): + # Nothing produces or authorizes these families, so no plan or record may carry them. + for key in ("repair:8.5.9:deadbeef", "auth_failure:deadbeef"): + self.assertIsNone(ACTION_KEY_RE.fullmatch(key), key) + for key in ("source_unhealthy:deadbeef", "health_failed:deadbeef", "policy_failure:deadbeef"): + self.assertIsNotNone(ACTION_KEY_RE.fullmatch(key), key) + def test_published_asset_mismatch_fails_closed(self): with tempfile.TemporaryDirectory() as temporary: root = pathlib.Path(temporary) @@ -1447,7 +1530,7 @@ def test_email_digest_selects_one_fixed_template_per_outcome(self): "installs of the plain version 8.5.9 now resolve to this revision", ), ( - {**base, "decision": changed, "plan": {"action": "needs_human", "actionKey": "auth_failure:" + "b" * 8}}, + {**base, "decision": changed, "plan": {"action": "needs_human", "actionKey": "policy_failure:" + "b" * 8}}, "watcher_attention", "needs_human", ), @@ -1920,6 +2003,295 @@ def test_token_created_prs_explicitly_dispatch_required_checks(self): release.index("Notify owner of completed release"), ) + @staticmethod + def _completed_record(action_key, version, assets, kind="published_release"): + def step(source, target, evidence): + return {"from": source, "to": target, "at": "2026-09-29T00:00:00Z", "evidence": evidence} + + return { + "schemaVersion": 1, + "actionKey": action_key, + "state": "complete", + "history": [ + step("release_requested", "released", [{"kind": kind, "version": version, "assetDigests": assets}]), + step("released", "public_install_verified", [{"kind": "fresh_public_mise_installs", "version": version}]), + step("public_install_verified", "complete", [{"kind": "transaction_complete"}]), + ], + } + + def test_release_event_recorded_accepts_only_this_release_complete_on_main(self): + key, version = "recipe_rebuild:8.5.11:3", "8.5.11-3" + assets = {"SHA256SUMS": "sha256:" + "1" * 64} + record = self._completed_record(key, version, assets) + self.assertFalse(release_event_recorded(None, key, version, assets)) + # A new branch's record waits on main short of complete until its release. + self.assertFalse(release_event_recorded({**record, "state": "mise_ready"}, key, version, assets)) + self.assertTrue(release_event_recorded(record, key, version, assets)) + self.assertTrue(release_event_recorded(record, key, version)) + # The watcher's recovered record names the release through its own evidence kind. + recovered = self._completed_record(key, version, assets, kind="published_immutable_release") + self.assertTrue(release_event_recorded(recovered, key, version, assets)) + # A complete record that names anything else contradicts the release. + for other, other_key, other_version, other_assets in ( + (record, "recipe_rebuild:8.5.11:2", version, assets), + (record, key, "8.5.11-2", assets), + (record, key, version, {"SHA256SUMS": "sha256:" + "2" * 64}), + ({**record, "history": record["history"][1:]}, key, version, assets), + (self._completed_record(key, version, assets, kind="other"), key, version, assets), + ): + with self.assertRaises(ControlError): + release_event_recorded(other, other_key, other_version, other_assets) + with self.assertRaises(ControlError): + release_event_recorded(record, key, "main", assets) + + def test_finalize_reruns_reuse_a_merged_record_and_withdraw_stale_branches(self): + from autorelease.verify import load_workflow + + root = pathlib.Path(__file__).resolve().parents[1] + jobs = load_workflow(root / ".github/workflows/autorelease-publish.yml")["jobs"] + steps = {step.get("name"): step for step in jobs["finalize"]["steps"]} + commit_step = steps["Commit final event record through a checked PR"] + merge_step = steps["Validate and merge final event record"] + state_step = steps["Record whether the event record merged"] + self.assertEqual("steps.event_pr.outputs.already_recorded == 'false'", merge_step["if"]) + self.assertIn("--require-protected-controls", merge_step["run"]) + self.assertEqual("always()", state_step["if"]) + self.assertEqual("autorelease/event-${{ github.run_id }}", commit_step["env"]["BRANCH"]) + + key, version = "recipe_rebuild:8.5.11:3", "8.5.11-3" + assets = {"SHA256SUMS": "sha256:" + "1" * 64} + record_path = f"autorelease-events/{action_filename(key)}" + commit_script = commit_step["run"].replace("${{ github.repository }}", "o/r") + state_script = state_step["run"] + + def git_in(path, *args): + return subprocess.run( + ["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@invalid", *args], + cwd=path, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ).stdout.strip() + + def fixture(work, main_record=None, stale_branch=False): + origin = work / "origin.git" + git_in(work, "init", "-q", "--bare", "-b", "main", str(origin)) + clone = work / "clone" + git_in(work, "clone", "-q", str(origin), str(clone)) + git_in(clone, "checkout", "-q", "-b", "main") + (clone / "autorelease-events").mkdir() + (clone / "autorelease-events/.keep").write_text("") + if main_record is not None: + (clone / record_path).write_text(json.dumps(main_record)) + git_in(clone, "add", "-A") + git_in(clone, "commit", "-q", "-m", "base") + git_in(clone, "push", "-q", "origin", "main") + if stale_branch: + # An earlier attempt's record commit, which a fresh commit cannot fast-forward. + git_in(clone, "checkout", "-q", "-b", "earlier-attempt") + (clone / record_path).write_text("earlier attempt\n") + git_in(clone, "add", "-A") + git_in(clone, "commit", "-q", "-m", "earlier attempt") + git_in(clone, "push", "-q", "origin", "HEAD:refs/heads/autorelease/event-77") + git_in(clone, "checkout", "-q", "main") + git_in(clone, "checkout", "-q", "--detach") + (clone / "autorelease").symlink_to(root / "autorelease") + (clone / "release-run").mkdir() + (clone / "release-run/transaction.json").write_text(json.dumps({"state": "complete", "assetDigests": assets})) + (clone / "release-run/event.json").write_text(json.dumps(self._completed_record(key, version, assets))) + (work / "bin").mkdir() + # gh lists the open PRs the case names, closes one by deleting its branch + # from the origin, and opens PR 9; every call is logged. + (work / "bin/gh").write_text( + "#!/usr/bin/env bash\n" + 'echo "$*" >> "$FAKE_LOG"\n' + 'case "$1 $2" in\n' + ' "pr list") printf "%s\\n" $FAKE_OPEN ;;\n' + ' "pr close") git -C "$FAKE_ORIGIN" branch -D autorelease/event-77 >/dev/null ;;\n' + ' "pr create") echo https://github.com/o/r/pull/9 ;;\n' + ' "auth setup-git") ;;\n' + " *) exit 3 ;;\n" + "esac\n" + ) + (work / "bin/gh").chmod(0o755) + return clone, origin + + def run_step(work, clone, origin, script, open_prs="", extra=None): + output = work / "output.txt" + output.write_text("") + env = { + **os.environ, + "PATH": f"{work / 'bin'}:{os.environ['PATH']}", + "ACTION_KEY": key, + "VERSION": version, + "BRANCH": "autorelease/event-77", + "GITHUB_OUTPUT": str(output), + "RUNNER_TEMP": str(work), + "FAKE_LOG": str(work / "gh.log"), + "FAKE_OPEN": open_prs, + "FAKE_ORIGIN": str(origin), + "GIT_AUTHOR_NAME": "Fixture", + "GIT_AUTHOR_EMAIL": "fixture@invalid", + "GIT_COMMITTER_NAME": "Fixture", + "GIT_COMMITTER_EMAIL": "fixture@invalid", + **(extra or {}), + } + result = subprocess.run( + ["bash", "-eo", "pipefail", "-c", script], cwd=clone, env=env, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + outputs = dict(line.split("=", 1) for line in output.read_text().splitlines() if "=" in line) + calls = (work / "gh.log").read_text() if (work / "gh.log").exists() else "" + return result, outputs, calls + + # A record an earlier attempt merged ends the step without a second record. + with tempfile.TemporaryDirectory() as temporary: + work = pathlib.Path(temporary) + clone, origin = fixture(work, self._completed_record(key, version, assets)) + result, outputs, calls = run_step(work, clone, origin, commit_script) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("true", outputs["already_recorded"]) + self.assertNotIn("pr create", calls) + self.assertEqual("", git_in(origin, "branch", "--list", "autorelease/*")) + + # An earlier attempt's open PR and branch are withdrawn and the record filed afresh. + for open_prs, stale_branch in (("5", True), ("", True), ("", False)): + with tempfile.TemporaryDirectory() as temporary: + work = pathlib.Path(temporary) + clone, origin = fixture(work, stale_branch=stale_branch) + result, outputs, calls = run_step(work, clone, origin, commit_script, open_prs) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("false", outputs["already_recorded"]) + self.assertEqual("9", outputs["number"]) + self.assertEqual(("pr close 5" in calls), bool(open_prs), calls) + head = git_in(origin, "rev-parse", "autorelease/event-77") + self.assertEqual(outputs["head_sha"], head) + self.assertEqual(f"{head} {outputs['base_sha']}", git_in(origin, "rev-list", "--parents", "-n", "1", head)) + self.assertEqual(record_path, git_in(origin, "diff", "--name-only", outputs["base_sha"], head)) + + # A new branch's incomplete record on main is completed through the PR. + with tempfile.TemporaryDirectory() as temporary: + work = pathlib.Path(temporary) + waiting = {**self._completed_record(key, version, assets), "state": "mise_ready", "history": []} + clone, origin = fixture(work, waiting) + result, outputs, _calls = run_step(work, clone, origin, commit_script) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("false", outputs["already_recorded"]) + + # A complete record on main for another release stops the step. + with tempfile.TemporaryDirectory() as temporary: + work = pathlib.Path(temporary) + clone, origin = fixture(work, self._completed_record(key, "8.5.11-2", assets)) + result, outputs, calls = run_step(work, clone, origin, commit_script) + self.assertNotEqual(0, result.returncode) + self.assertNotIn("already_recorded", outputs) + self.assertNotIn("pr create", calls) + + # The retained state says recorded exactly when the record is on main. + for main_record, merge_outcome, already, expected in ( + (None, "success", "false", True), + (None, "", "true", True), + (None, "failure", "false", False), + (None, "", "", False), + (self._completed_record(key, version, assets), "failure", "false", True), + (self._completed_record(key, version, assets), "", "", True), + (self._completed_record(key, "8.5.11-2", assets), "", "", False), + ): + with tempfile.TemporaryDirectory() as temporary: + work = pathlib.Path(temporary) + clone, origin = fixture(work, main_record) + result, _outputs, _calls = run_step( + work, clone, origin, state_script, extra={"MERGE_OUTCOME": merge_outcome, "ALREADY_RECORDED": already} + ) + self.assertEqual(0, result.returncode, result.stderr) + state = json.loads((clone / "release-run/transaction-state.json").read_text()) + self.assertEqual( + {"schemaVersion": 1, "released": True, "recorded": expected, "version": version}, + state, + (main_record is not None, merge_outcome, already), + ) + + def test_publish_runs_email_their_own_digest_exactly_once(self): + from autorelease.verify import load_workflow + + root = pathlib.Path(__file__).resolve().parents[1] + workflows = root / ".github/workflows" + email = load_workflow(workflows / "autorelease-email.yml") + # YAML 1.1 reads the bare `on` key as true. + triggers = email.get("on") or email["true"] + # The watcher is started by its schedule or a person, so workflow_run reaches it. + # GitHub starts no workflow_run for a publish run GITHUB_TOKEN dispatched, and a + # publish run dispatched any other way must not email twice, so publish is not a + # workflow_run trigger at all and reaches the digest only by calling it. + self.assertEqual(["PHP autorelease watcher"], triggers["workflow_run"]["workflows"]) + call = triggers["workflow_call"] + self.assertEqual({"run_id", "run_attempt", "workflow", "conclusion"}, set(call["inputs"])) + self.assertTrue(all(spec["required"] and spec["type"] == "string" for spec in call["inputs"].values())) + self.assertEqual({"RESEND_API_KEY": {"required": False}}, call["secrets"]) + digest = email["jobs"]["digest"] + self.assertEqual("${{ inputs.run_id || github.event.workflow_run.id }}", digest["env"]["RUN_ID"]) + self.assertEqual("${{ inputs.conclusion || github.event.workflow_run.conclusion }}", digest["env"]["RUN_CONCLUSION"]) + self.assertIn("autorelease-email-${{ inputs.run_id || github.event.workflow_run.id }}", digest["concurrency"]["group"]) + download = next(step for step in digest["steps"] if step.get("name") == "Download the retained run state") + self.assertIn('if [[ "$CALLED_WORKFLOW" != publish ]]; then', download["run"]) + # An unconfigured repository still skips quietly on both routes. + gate = next(step for step in digest["steps"] if step.get("name") == "Decide whether delivery is configured") + self.assertIn('-n "$RESEND_API_KEY"', gate["run"]) + # The secret is scoped to the two steps that read it, never to the whole job. + self.assertNotIn("RESEND_API_KEY", digest["env"]) + self.assertEqual( + ["Decide whether delivery is configured", "Send the digest through Resend"], + [step.get("name") for step in digest["steps"] if "RESEND_API_KEY" in (step.get("env") or {})], + ) + # Retries reuse one idempotency key per run attempt, so none can send twice. + send = next(step for step in digest["steps"] if step.get("name") == "Send the digest through Resend") + self.assertIn("--retry 3", send["run"]) + self.assertIn('--header "Idempotency-Key: php-bin-$WORKFLOW-$RUN_ID-$RUN_ATTEMPT"', send["run"]) + self.assertEqual("${{ steps.state.outputs.workflow }}", send["env"]["WORKFLOW"]) + + callers = { + path.name: [ + name for name, job in (load_workflow(path).get("jobs") or {}).items() + if job.get("uses") == "./.github/workflows/autorelease-email.yml" + ] + for path in workflows.glob("*.yml") + } + self.assertEqual({"autorelease-publish.yml": ["email"]}, {name: jobs for name, jobs in callers.items() if jobs}) + jobs = load_workflow(workflows / "autorelease-publish.yml")["jobs"] + caller = jobs["email"] + self.assertEqual("always()", caller["if"]) + self.assertEqual(set(jobs) - {"email"}, set(caller["needs"])) + self.assertEqual({"actions": "read", "contents": "read"}, caller["permissions"]) + self.assertEqual({"RESEND_API_KEY": "${{ secrets.RESEND_API_KEY }}"}, caller["secrets"]) + self.assertEqual( + { + "run_id": "${{ github.run_id }}", + "run_attempt": "${{ github.run_attempt }}", + "workflow": "publish", + "conclusion": "${{ contains(needs.*.result, 'failure') && 'failure' || " + "contains(needs.*.result, 'cancelled') && 'cancelled' || 'success' }}", + }, + caller["with"], + ) + + def test_automation_pull_requests_are_filed_with_real_newlines_and_exact_leases(self): + root = pathlib.Path(__file__).resolve().parents[1] + for path in (root / ".github/workflows").glob("*.yml"): + body = path.read_text() + # A double-quoted shell string keeps "\n" as two characters. + self.assertIsNone(re.search(r'--body "[^"]*\\n', body), path.name) + # Every lease is taken against a tracking ref fetched with a forced refspec, + # so it is exactly the branch head the remote reports. + for match in re.finditer(r"git fetch origin \"([^\"]*)\"", body): + self.assertTrue(match.group(1).startswith("+refs/heads/"), (path.name, match.group(1))) + if "--force-with-lease" in body: + self.assertIn('git fetch origin "+refs/heads/$branch:refs/remotes/origin/$branch"', body, path.name) + # Every single-record merge also asserts the Protected controls check. + self.assertEqual( + body.count("./scripts/merge-record-pr"), + len(re.findall(r"\./scripts/merge-record-pr[^;]*?--require-protected-controls", body, re.DOTALL)), + path.name, + ) + implement = (root / ".github/workflows/autorelease-implement.yml").read_text() + self.assertIn('printf "Deterministically sealed autorelease patch for \\`%s\\`.\\n\\nValidated commit: \\`%s\\`."', implement) + def test_release_build_runs_apart_from_the_write_token(self): # StaticPHP runs third-party build scripts, so it may only run in a job # whose token reads contents, and the write-scoped release job may only