diff --git a/.github/agent-guides/catalogue.md b/.github/agent-guides/catalogue.md index 48fa2a1..7a83f28 100644 --- a/.github/agent-guides/catalogue.md +++ b/.github/agent-guides/catalogue.md @@ -9,7 +9,7 @@ This repository also hosts the suite's shared CI/CD building blocks: composite a [`.github/workflows/`](../../.github/workflows/). They were imported from `devantler-tech/actions` as one snapshot (#235). The one-time importer is retired and preserved in Git history; the current catalogue and its documentation are authored here, not generated from that historical source. -That repository is frozen and is archived once nothing references it (#164). Consumers reference +That repository is frozen pending verified migration and retirement (#240). Consumers reference the catalogue here as: ```yaml @@ -35,7 +35,7 @@ inside `test-create-release-config` in required CI. Never treat its diagnosis as or write authorization; #587 owns publication, legacy compatibility and protected consumer proof. The same Go module holds a separate `cmd/publish-release-recovery` command. Only the default-off -reusable `publish-release-recovery.yaml` calls it, after a confirmed manual dispatch, a read-only +repository-owned manual `publish-release-recovery.yaml` calls it, after a confirmed dispatch, a read-only admission and only then a writer token; run `bash .github/tests/test-publish-release-recovery.sh` after changing it. It re-runs the inspector's admission with its writer credential, so the inspection command stays read-only. It refuses to publish unless that credential can push, two complete release @@ -48,6 +48,13 @@ that claims nothing it did not check. It never retries a write. A server error, a lost connection or a failed readback is reported as an unknown outcome, naming any release it created, and a later run reconciles it through fresh reads. +No production consumer adopted this recovery workflow. It is not a public reusable interface: +other products own their recovery workflow until real repeated use justifies extraction. +`release-recovery-offline-fixture.yaml` is an internal generated projection containing only the +complete credential-free offline job, with no publisher job or secret interface. Regenerate it +with `bash .github/scripts/generate-release-recovery-fixture.sh`; the independent production +parity, generator equality, native enabled/disabled controls and omission mutations stay required. + The credential-free release fixture also exercises stranded stable tags with absent or matching default-channel notes, for both lightweight and annotated tags. It verifies no release at the tagged commit, the next patch after a subsequent fix, and unchanged consumer files and local/remote refs. @@ -63,8 +70,8 @@ actions// # One directory per composite action .scripts/ # Scripts shared by composite actions, reached as ${GITHUB_ACTION_PATH}/../../.scripts .github/ -├── workflows/ # ALL workflows (GitHub requires it). A workflow with `workflow_call` is a -│ # catalogue product; repository-owned workflows are listed below. +├── workflows/ # ALL workflows (GitHub requires it). Public `workflow_call` products +│ # and explicitly internal generated fixtures are indexed separately. ├── actions/ # internal composite actions the reusable workflows check out and run ├── fixtures/ # fixtures for the action `test-` jobs ├── scripts/ # helpers the reusable workflows run @@ -98,8 +105,10 @@ One component may reference another **in this repository**. GitHub resolves thes - **CI job → workspace action:** existing `uses: ./actions/` references require `actions/checkout`. They resolve from workspace bytes, not the defining file; the broader native-reference migration and its cross-repository proof are tracked in #271. -- **Reusable workflow → co-located reusable workflow:** normally - `uses: ./.github/workflows/.yaml` (same repository, same commit). An organization-required +- **Reusable workflow → co-located reusable workflow:** new calls use native + `uses: $/.github/workflows/.yaml` (the defining file's repository and commit). + Existing classic `./.github/workflows/` calls retain their source-boundary controls during #271. + An organization-required workflow is injected into a consumer repository, where that path resolves against the consumer instead; it must call a sibling workflow through a full remote reference pinned to an immutable commit whose workflow content has been reviewed, and every caller job is covered by a regression @@ -197,6 +206,7 @@ cutover; the generic budget bound is not permission to omit tests or declare tha | `measure-go-disk.yaml` | This repository | | `organization-settings-audit.yaml` | This repository | | `pr-metadata-guards.yaml` | This repository | +| `publish-release-recovery.yaml` | This repository; default-off confirmed manual recovery, not a reusable consumer interface | | `refresh-gh-digests.yaml` | This repository | | `release.yaml` | This repository | | `repository-admin-team-audit.yaml` | This repository | diff --git a/.github/agent-guides/workflows.md b/.github/agent-guides/workflows.md index 0bfde44..c270eee 100644 --- a/.github/agent-guides/workflows.md +++ b/.github/agent-guides/workflows.md @@ -53,6 +53,11 @@ Actions and reusable workflows are exercised as jobs inside [`ci.yaml`](../../.g `ci-required-checks` is the sole exception to the harden-runner-first rule: adding any action would weaken its workspace-independent trust boundary. Every other step-bearing job must start with SHA-pinned `step-security/harden-runner` in audit mode, and `lint-ci-coverage-parity` enforces both sides of that contract. +Reusable-workflow coverage reads parsed job-level `uses` values and requires an exact classic +`./.github/workflows/` or native `$/.github/workflows/` match. New same-repository calls use native +syntax. A comment, step-level reference, display name or filename prefix is not job execution. +The real inline guard's behavioral controls cover both forms and those non-call refusals. + **TODO coverage:** both TODO smoke callers use the complete generated `scan-for-todo-comments-readonly.yaml` projection with contents-read permissions and no App secret. Regenerate with `bash .github/scripts/generate-todo-readonly.sh`; never edit @@ -240,7 +245,9 @@ A new job/step/behaviour must not go live for every consumer the moment it merge **Gotchas:** -- `workflow_dispatch` passes booleans as **strings** while `workflow_call` passes real **booleans** — a workflow triggered by both must normalize before comparing: `if: ${{ inputs. == true || inputs. == 'true' }}`. +- The `inputs` context preserves declared booleans for both `workflow_dispatch` and `workflow_call`. + Only `github.event.inputs` converts manual inputs to strings. Use `inputs.` for typed + gates; see [GitHub's input-context documentation](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow#defining-inputs-for-manually-triggered-workflows). - You **cannot** wrap `if:` around an individual `with:` input — gate the whole **step** (or **job**), or select the value with a ternary in the `with:` value (`${{ inputs. && 'a' || 'b' }}`). - **Test both states.** Per the feature-flag-first "test both states" rule, cover the flag **off** (default) and **on** with `[Test]` jobs (see *Test jobs* above) — a flag whose off-path is untested can regress silently. diff --git a/.github/scripts/generate-release-recovery-fixture.sh b/.github/scripts/generate-release-recovery-fixture.sh new file mode 100755 index 0000000..6f68603 --- /dev/null +++ b/.github/scripts/generate-release-recovery-fixture.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Project only the complete credential-free offline job; never export the publisher. +set -euo pipefail +[[ $# -le 2 ]] || { echo 'Usage: generate-release-recovery-fixture.sh [source [destination]]' >&2; exit 2; } +root="$(cd "$(dirname "$0")/../.." && pwd)" +source="${1:-$root/.github/workflows/publish-release-recovery.yaml}" +destination="${2:-$root/.github/workflows/release-recovery-offline-fixture.yaml}" +work="$(mktemp)" +trap 'rm -f "$work"' EXIT +yq ' + .name = "Internal release-recovery offline fixture" | + .on = {"workflow_call": {"inputs": (.on.workflow_dispatch.inputs | + with_entries(select(.key == "enable-recovery" or .key == "offline-test")))}} | + .jobs = {"offline-test": .jobs.offline-test} +' "$source" > "$work" +{ + printf '# Generated by .github/scripts/generate-release-recovery-fixture.sh; do not edit.\n' + printf '# Internal credential-free native validation only, not a supported consumer interface.\n' + cat "$work" +} > "$destination" diff --git a/.github/tests/test-publish-release-recovery.sh b/.github/tests/test-publish-release-recovery.sh index 8d7f6d7..1b4ef46 100755 --- a/.github/tests/test-publish-release-recovery.sh +++ b/.github/tests/test-publish-release-recovery.sh @@ -12,12 +12,15 @@ fail() { printf 'release recovery: %s\n' "$1" >&2; exit 1; } [[ -f "$workflow" ]] || fail 'recovery workflow is missing' yq -o=json '.' "$workflow" > "$work/workflow.json" yq -o=json '.' "$ci" > "$work/ci.json" -jq -n --slurpfile w "$work/workflow.json" --slurpfile c "$work/ci.json" '{w:$w[0],c:$c[0]}' > "$work/bundle.json" +fixture="$root/.github/workflows/release-recovery-offline-fixture.yaml" +[[ -f "$fixture" ]] || fail 'internal recovery fixture is missing' +yq -o=json '.' "$fixture" > "$work/fixture.json" +jq -n --slurpfile w "$work/workflow.json" --slurpfile c "$work/ci.json" --slurpfile f "$work/fixture.json" '{w:$w[0],c:$c[0],f:$f[0]}' > "$work/bundle.json" cat > "$work/guard.jq" <<'JQ' .w as $w | .c as $c | $w.jobs.recover as $j | $j.steps as $s | $w.jobs["offline-test"] as $o | def ordinary: .if == null and .["continue-on-error"] == null; def writer: .if == "steps.read-admission.outputs.missing == 'true'" and .["continue-on-error"] == null; - def source: .env == {CATALOGUE_REPOSITORY:"${{ job.workflow_repository }}",CATALOGUE_SHA:"${{ job.workflow_sha }}"} and + def source: .env == {CATALOGUE_REPOSITORY:"${{ github.repository }}",CATALOGUE_SHA:"${{ github.workflow_sha }}"} and (.run | contains("[[ \"$CATALOGUE_REPOSITORY\" == devantler-tech/.github && \"$CATALOGUE_SHA\" =~ ^[0-9a-f]{40}$ ]]")) and (.run | contains("env -i PATH=\"$PATH\" curl --disable")) and (.run | contains("https://codeload.github.com/$CATALOGUE_REPOSITORY/tar.gz/$CATALOGUE_SHA")) and @@ -26,12 +29,12 @@ cat > "$work/guard.jq" <<'JQ' (.run | contains("build -trimpath -o \"$RUNNER_TEMP/publish-release-recovery\" ./cmd/publish-release-recovery")); def incident: {REPOSITORY:"${{ github.repository }}",TAG:"${{ inputs.tag }}",SOURCE_SHA:"${{ inputs.source-sha }}",RUN_ID:"${{ inputs.run-id }}",RUN_ATTEMPT:"${{ inputs.run-attempt }}",PRODUCER_REF:"${{ inputs.producer-ref }}"}; def token($contents): {"client-id":"${{ vars.APP_CLIENT_ID }}","private-key":"${{ secrets.APP_PRIVATE_KEY }}",owner:"devantler-tech",repositories:"${{ steps.admit.outputs.name }}","permission-contents":$contents,"permission-actions":"read"}; - ($w.on | keys) == ["workflow_call"] and $w.permissions == {} and - $w.on.workflow_call.inputs["enable-recovery"] == {description:$w.on.workflow_call.inputs["enable-recovery"].description,type:"boolean",default:false} and - $w.on.workflow_call.inputs["confirm-publication"].type == "boolean" and $w.on.workflow_call.inputs["confirm-publication"].default == false and - $w.on.workflow_call.inputs["offline-test"].default == false and - ($w.on.workflow_call.inputs | has("repository") | not) and - ($w.on.workflow_call.secrets | keys) == ["APP_PRIVATE_KEY"] and + ($w.on | keys) == ["workflow_dispatch"] and $w.permissions == {} and + $w.on.workflow_dispatch.inputs["enable-recovery"] == {description:$w.on.workflow_dispatch.inputs["enable-recovery"].description,type:"boolean",default:false} and + $w.on.workflow_dispatch.inputs["confirm-publication"].type == "boolean" and $w.on.workflow_dispatch.inputs["confirm-publication"].default == false and + $w.on.workflow_dispatch.inputs["offline-test"].default == false and + ($w.on.workflow_dispatch.inputs | has("repository") | not) and + $w.on.workflow_dispatch.secrets == null and ($w.jobs | keys) == ["offline-test","recover"] and $j.if == "inputs.enable-recovery && !inputs.offline-test" and $j.permissions == {} and $j.needs == null and $j.concurrency == {group:"create-release-${{ github.repository }}-${{ github.ref }}","cancel-in-progress":false,queue:"max"} and @@ -55,9 +58,12 @@ cat > "$work/guard.jq" <<'JQ' ($o.steps[1] | source) and ($o.steps[3] | build) and ([$o.steps[] | tostring | select(test("secrets\\.|outputs\\.token|github\\.token"))] | length == 0) and ([$o.steps[] | select(.env.GH_TOKEN != null)] | length == 0) and ($o.steps[4].run | contains("unset GH_TOKEN GITHUB_TOKEN")) and - ($c.jobs["test-publish-release-recovery"] | .uses == "./.github/workflows/publish-release-recovery.yaml" and .permissions == {} and .secrets == null and + .f == {name:"Internal release-recovery offline fixture",permissions:{}, + on:{workflow_call:{inputs:($w.on.workflow_dispatch.inputs|with_entries(select(.key=="enable-recovery" or .key=="offline-test")))}}, + jobs:{"offline-test":$o}} and + ($c.jobs["test-publish-release-recovery"] | .uses == "$/.github/workflows/release-recovery-offline-fixture.yaml" and .permissions == {} and .secrets == null and .with["enable-recovery"] == true and .with["offline-test"] == true and .with["confirm-publication"] == null) and - ($c.jobs["test-publish-release-recovery-disabled"] | .uses == "./.github/workflows/publish-release-recovery.yaml" and .permissions == {} and .secrets == null and + ($c.jobs["test-publish-release-recovery-disabled"] | .uses == "$/.github/workflows/release-recovery-offline-fixture.yaml" and .permissions == {} and .secrets == null and .with["enable-recovery"] == null and .with["offline-test"] == null) and (["test-publish-release-recovery","test-publish-release-recovery-disabled"] | all(. as $job | ($c.jobs["ci-required-checks"].needs | index($job) != null) and @@ -65,12 +71,17 @@ cat > "$work/guard.jq" <<'JQ' ($c.jobs["test-create-release-config"].steps | any(.run == "bash .github/tests/test-publish-release-recovery.sh" and ordinary)) JQ jq -e -f "$work/guard.jq" "$work/bundle.json" >/dev/null || fail 'workflow authority, provenance or required coverage is unsafe' +# Exercise the generator too; the independent parsed equality above is the oracle. +bash "$root/.github/scripts/generate-release-recovery-fixture.sh" "$workflow" "$work/regenerated.yaml" +yq -o=json '.' "$work/regenerated.yaml" | jq -S . > "$work/regenerated.json" +jq -S . "$work/fixture.json" > "$work/checked.json" +cmp -s "$work/regenerated.json" "$work/checked.json" || fail 'internal recovery fixture needs regeneration' # shellcheck disable=SC2016 # Keep GitHub expressions literal in refusal controls. for mutation in \ - '.w.on.workflow_dispatch={}' \ - '.w.on.workflow_call.inputs["enable-recovery"].default=true' \ - '.w.on.workflow_call.inputs["confirm-publication"].default=true' \ - '.w.on.workflow_call.inputs.repository={type:"string"}' \ + '.w.on.workflow_call={}' \ + '.w.on.workflow_dispatch.inputs["enable-recovery"].default=true' \ + '.w.on.workflow_dispatch.inputs["confirm-publication"].default=true' \ + '.w.on.workflow_dispatch.inputs.repository={type:"string"}' \ '.w.jobs.recover.if="inputs.enable-recovery"' \ '.w.jobs.recover.permissions.contents="write"' \ '.w.jobs.recover.concurrency["cancel-in-progress"]=true' \ @@ -97,7 +108,15 @@ for mutation in \ '.c.jobs["test-publish-release-recovery-disabled"].with["enable-recovery"]=true' \ '.c.jobs["ci-required-checks"].needs |= map(select(. != "test-publish-release-recovery"))' \ '.c.jobs["ci-required-checks"].steps |= map(if .env.JOB_RESULTS then .env.JOB_RESULTS |= sub("needs.test-publish-release-recovery-disabled.result"; "") else . end)' \ - '.c.jobs["test-create-release-config"].steps |= map(select(.run != "bash .github/tests/test-publish-release-recovery.sh"))'; do + '.c.jobs["test-create-release-config"].steps |= map(select(.run != "bash .github/tests/test-publish-release-recovery.sh"))' \ + '.f.jobs.recover=.w.jobs.recover' \ + '.f.on.workflow_call.secrets.APP_PRIVATE_KEY={required:false}' \ + '.f.jobs["offline-test"].permissions.contents="write"' \ + '.f.jobs["offline-test"].steps[1].env.CATALOGUE_SHA="main"' \ + '.f.jobs["offline-test"].steps |= del(.[3])' \ + '.f.jobs["offline-test"].steps[4].if="false"' \ + '.f.on.workflow_call.inputs["offline-test"].default=true' \ + '.c.jobs["test-publish-release-recovery"].uses="./.github/workflows/publish-release-recovery.yaml"'; do jq "$mutation" "$work/bundle.json" > "$work/mutated.json" if jq -e -f "$work/guard.jq" "$work/mutated.json" >/dev/null; then fail "accepted mutation: $mutation"; fi done diff --git a/.github/tests/test-test-script-wiring.sh b/.github/tests/test-test-script-wiring.sh index 668ca9b..2690da6 100644 --- a/.github/tests/test-test-script-wiring.sh +++ b/.github/tests/test-test-script-wiring.sh @@ -39,6 +39,35 @@ blocked() { } run_guard || fail 'empty test directory should be accepted' +# Coverage must recognize real job-level calls in both same-repository forms, +# without treating a comment, step call or similarly named workflow as execution. +printf 'on:\n workflow_call: {}\njobs: {}\n' >"$work/repo/.github/workflows/native-fixture.yaml" +for reference in './.github/workflows/native-fixture.yaml' '$/.github/workflows/native-fixture.yaml'; do + # shellcheck disable=SC2016 # Preserve the required-result expression as fixture data. + REFERENCE="$reference" yq '.jobs.workflow-test = {"uses": strenv(REFERENCE)} | + .jobs.ci-required-checks.needs += ["workflow-test"] | + .jobs.ci-required-checks.steps[0].env.JOB_RESULTS += " ${{ needs.workflow-test.result }}"' \ + "$work/base.yaml" >"$ci" + run_guard || fail "same-repository workflow call was not covered: $reference" +done +for mutation in \ + '.jobs.workflow-test.uses += ".extra"' \ + '.jobs.workflow-test.name = .jobs.workflow-test.uses | del(.jobs.workflow-test.uses)' \ + '.jobs.workflow-test.steps = [{"uses": .jobs.workflow-test.uses}] | del(.jobs.workflow-test.uses)'; do + yq "$mutation" "$ci" >"$work/bad-call.yaml" + cp "$work/bad-call.yaml" "$ci" + printf '# uses: ./.github/workflows/native-fixture.yaml\n' >>"$ci" + if run_guard; then fail 'non-job or inexact workflow reference counted as coverage'; fi + grep -Fq 'native-fixture.yaml' "$work/result" || fail 'missing reusable-workflow diagnostic' + # shellcheck disable=SC2016 # The GitHub required-result expression stays literal fixture data. + REFERENCE='$/.github/workflows/native-fixture.yaml' yq '.jobs.workflow-test = {"uses": strenv(REFERENCE)} | + .jobs.ci-required-checks.needs += ["workflow-test"] | + .jobs.ci-required-checks.steps[0].env.JOB_RESULTS += " ${{ needs.workflow-test.result }}"' \ + "$work/base.yaml" >"$ci" +done +rm "$work/repo/.github/workflows/native-fixture.yaml" +cp "$work/base.yaml" "$ci" +echo 'PASS: exact job-level classic/native workflow calls and three non-call controls' touch "$work/repo/.github/tests/test-sentinel.sh" blocked 'new test without a CI step' diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e287cb3..4d1e032 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -3609,7 +3609,7 @@ jobs: # (1) every composite action (actions/*/action.yaml) is exercised by a job # that does `uses: ./`; # (1b) every reusable workflow (workflow_call) is exercised by a job - # that does `uses: ./.github/workflows/.yaml`; and + # with an exact classic or native same-repository call; and # (2) ci-required-checks.needs and its trusted inline JOB_RESULTS # environment value name the SAME set of jobs — a job in # `needs:` but missing from `JOB_RESULTS` has its failure @@ -3634,7 +3634,8 @@ jobs: [[ "$wf" == "$ci" ]] && continue grep -qE "^[[:space:]]+workflow_call:" "$wf" || continue wname="$(basename "$wf")" - if ! grep -qE "uses:[[:space:]]*\./\.github/workflows/$wname([[:space:]]|\$)" "$ci"; then + if ! WORKFLOW_LOCAL="./.github/workflows/$wname" WORKFLOW_NATIVE="\$/.github/workflows/$wname" \ + yq -e '.jobs | to_entries | any_c(.value.uses == strenv(WORKFLOW_LOCAL) or .value.uses == strenv(WORKFLOW_NATIVE))' "$ci" >/dev/null; then # TODO smoke calls use the complete read-only projection. Accept # production coverage only after permission, execution and parity # checks pass; a copied or truncated workflow is insufficient. @@ -4136,28 +4137,17 @@ jobs: test-publish-release-recovery: if: "${{ github.event_name != 'merge_group' && !startsWith(github.event.head_commit.message, 'chore(main): release ') }}" name: "[Test] Publish Release Recovery - Offline" - uses: ./.github/workflows/publish-release-recovery.yaml + uses: $/.github/workflows/release-recovery-offline-fixture.yaml permissions: {} with: enable-recovery: true offline-test: true - tag: v1.2.3 - source-sha: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - run-id: "1" - run-attempt: "1" - producer-ref: devantler-tech/.github/.github/workflows/create-release.yaml@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb test-publish-release-recovery-disabled: if: "${{ github.event_name != 'merge_group' && !startsWith(github.event.head_commit.message, 'chore(main): release ') }}" name: "[Test] Publish Release Recovery - Disabled" - uses: ./.github/workflows/publish-release-recovery.yaml + uses: $/.github/workflows/release-recovery-offline-fixture.yaml permissions: {} - with: - tag: v1.2.3 - source-sha: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - run-id: "1" - run-attempt: "1" - producer-ref: devantler-tech/.github/.github/workflows/create-release.yaml@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb test-create-release-config: needs: [select-ci-tests] diff --git a/.github/workflows/publish-release-recovery.yaml b/.github/workflows/publish-release-recovery.yaml index 2232d43..55b348b 100644 --- a/.github/workflows/publish-release-recovery.yaml +++ b/.github/workflows/publish-release-recovery.yaml @@ -1,12 +1,13 @@ name: 🩹 Publish Release Recovery # Default-off recovery of one GitHub release whose stable tag was pushed but never published (#602). -# The caller is the affected public repository itself, dispatched manually from its default branch. +# Repository-owned manual operation; no production reusable consumer exists (#722). +# Other products must own their recovery workflow until real repeated use justifies extraction. # `enable-recovery` is the temporary rollout flag (#603); `confirm-publication` is the permanent # operator consent and stays when the flag is retired. Guarded by # .github/tests/test-publish-release-recovery.sh. on: - workflow_call: + workflow_dispatch: inputs: enable-recovery: description: "Temporary rollout flag; recovery is skipped unless true" @@ -40,9 +41,6 @@ on: description: "Original catalogue create-release workflow path at its full commit SHA" type: string required: true - secrets: - APP_PRIVATE_KEY: - required: false permissions: {} @@ -89,8 +87,8 @@ jobs: - name: 📑 Download immutable recovery source shell: bash env: - CATALOGUE_REPOSITORY: ${{ job.workflow_repository }} - CATALOGUE_SHA: ${{ job.workflow_sha }} + CATALOGUE_REPOSITORY: ${{ github.repository }} + CATALOGUE_SHA: ${{ github.workflow_sha }} run: | set -euo pipefail [[ "$CATALOGUE_REPOSITORY" == devantler-tech/.github && "$CATALOGUE_SHA" =~ ^[0-9a-f]{40}$ ]] || { @@ -212,8 +210,8 @@ jobs: - name: 📑 Download immutable recovery source shell: bash env: - CATALOGUE_REPOSITORY: ${{ job.workflow_repository }} - CATALOGUE_SHA: ${{ job.workflow_sha }} + CATALOGUE_REPOSITORY: ${{ github.repository }} + CATALOGUE_SHA: ${{ github.workflow_sha }} run: | set -euo pipefail [[ "$CATALOGUE_REPOSITORY" == devantler-tech/.github && "$CATALOGUE_SHA" =~ ^[0-9a-f]{40}$ ]] || { diff --git a/.github/workflows/release-recovery-offline-fixture.yaml b/.github/workflows/release-recovery-offline-fixture.yaml new file mode 100644 index 0000000..14028e4 --- /dev/null +++ b/.github/workflows/release-recovery-offline-fixture.yaml @@ -0,0 +1,95 @@ +# Generated by .github/scripts/generate-release-recovery-fixture.sh; do not edit. +# Internal credential-free native validation only, not a supported consumer interface. +name: Internal release-recovery offline fixture +# Default-off recovery of one GitHub release whose stable tag was pushed but never published (#602). +# Repository-owned manual operation; no production reusable consumer exists (#722). +# Other products must own their recovery workflow until real repeated use justifies extraction. +# `enable-recovery` is the temporary rollout flag (#603); `confirm-publication` is the permanent +# operator consent and stays when the flag is retired. Guarded by +# .github/tests/test-publish-release-recovery.sh. +on: + workflow_call: + inputs: + enable-recovery: + description: "Temporary rollout flag; recovery is skipped unless true" + type: boolean + default: false + offline-test: + description: "Build the publisher and check its credential refusal without any production credential" + type: boolean + default: false +permissions: {} +jobs: + offline-test: + if: inputs.enable-recovery && inputs.offline-test + name: Offline recovery build and credential refusal + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: {} + steps: + - name: "\U0001F6E1️ Harden runner" + uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 + with: + egress-policy: audit + - name: "\U0001F4D1 Download immutable recovery source" + shell: bash + env: + CATALOGUE_REPOSITORY: ${{ github.repository }} + CATALOGUE_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + [[ "$CATALOGUE_REPOSITORY" == devantler-tech/.github && "$CATALOGUE_SHA" =~ ^[0-9a-f]{40}$ ]] || { + echo '::error::invalid catalogue source' >&2 + exit 1 + } + env -i PATH="$PATH" curl --disable --fail --silent --show-error --location \ + --proto '=https' --proto-redir '=https' --connect-timeout 10 --max-time 120 --retry 3 \ + --output "$RUNNER_TEMP/recovery.tar.gz" \ + "https://codeload.github.com/$CATALOGUE_REPOSITORY/tar.gz/$CATALOGUE_SHA" + mkdir .devantler-tech-recovery + tar --extract --gzip --file="$RUNNER_TEMP/recovery.tar.gz" \ + --strip-components=1 --directory=.devantler-tech-recovery + for source_file in go.mod main.go cmd/publish-release-recovery/main.go; do + [[ -f ".devantler-tech-recovery/.github/scripts/inspect-release-recovery/$source_file" && + ! -L ".devantler-tech-recovery/.github/scripts/inspect-release-recovery/$source_file" ]] || { + echo '::error::incomplete recovery source' >&2 + exit 1 + } + done + - name: "\U0001F4E6 Setup Go" + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: "1.26.9" + cache: false + - name: "\U0001F528 Build without credentials" + env: + GOWORK: "off" + GOFLAGS: "" + GOTOOLCHAIN: local + CGO_ENABLED: "0" + run: | + set -euo pipefail + go -C .devantler-tech-recovery/.github/scripts/inspect-release-recovery build -trimpath -o "$RUNNER_TEMP/inspect-release-recovery" . + go -C .devantler-tech-recovery/.github/scripts/inspect-release-recovery build -trimpath -o "$RUNNER_TEMP/publish-release-recovery" ./cmd/publish-release-recovery + - name: "\U0001F9EA Refuse publication without a credential" + shell: bash + run: | + set -euo pipefail + unset GH_TOKEN GITHUB_TOKEN + if "$RUNNER_TEMP/publish-release-recovery" \ + --repository devantler-tech/.github --tag v1.2.3 --source-sha "$(printf 'a%.0s' {1..40})" \ + --run-id 1 --run-attempt 1 \ + --producer-ref "devantler-tech/.github/.github/workflows/create-release.yaml@$(printf 'b%.0s' {1..40})" \ + > "$RUNNER_TEMP/refusal.out" 2> "$RUNNER_TEMP/refusal.err"; then + echo '::error::the publisher ran without a credential' >&2 + exit 1 + fi + grep -Fq 'a non-empty GH_TOKEN without whitespace is required' "$RUNNER_TEMP/refusal.err" || { + echo '::error::the publisher stopped for an unexpected reason' >&2 + cat "$RUNNER_TEMP/refusal.err" >&2 + exit 1 + } + [[ ! -s "$RUNNER_TEMP/refusal.out" ]] || { + echo '::error::the refused publisher printed a result' >&2 + exit 1 + } diff --git a/README.md b/README.md index d82e3de..66790ac 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,6 @@ single-product pipeline. | [Lint](.github/workflows/lint.yaml) | Run MegaLinter with isolated signed-fix handling | | [Publish app](.github/workflows/publish-app.yaml) | Build, sign and publish an application image | | [Publish manifests](.github/workflows/publish-manifests.yaml) | Build, sign and publish a manifest bundle | -| [Publish release recovery](.github/workflows/publish-release-recovery.yaml) | Publish a missing GitHub release for an existing stable tag; default-off, manual only | | [Scan TODO comments](.github/workflows/scan-for-todo-comments.yaml) | Turn actionable TODOs into tracked work | | [Scan workflow vulnerabilities](.github/workflows/scan-for-workflow-vulnerabilities.yaml) | Analyze workflow security; also an organization PR gate | | [Template sync](.github/workflows/template-sync.yaml) | Open updates from an upstream template | @@ -130,6 +129,7 @@ These operate this catalogue itself; they are not reusable consumer interfaces. | Workflow | Description | |---|---| | [Inspect release recovery](.github/workflows/inspect-release-recovery.yaml) | Diagnose a stranded stable release without publication authority | +| [Publish release recovery](.github/workflows/publish-release-recovery.yaml) | Recover this catalogue's stranded stable release; default-off and confirmation-gated | ## Organization-required workflow sources @@ -166,6 +166,7 @@ do not establish a product need for a shared workflow. | [Applied-fix signing](.github/workflows/apply-signed-fixes.yaml) | Isolated, shared signer for eligible fixer artifacts | | [TODO read-only smoke](.github/workflows/scan-for-todo-comments-readonly.yaml) | Generated projection for credential-free scanner tests | | [TODO default fixture](.github/workflows/scan-for-todo-comments-default-fixture.yaml) | Offline dependencies for testing the production defaults | +| [Release-recovery offline fixture](.github/workflows/release-recovery-offline-fixture.yaml) | Generated complete offline build/refusal job, with no publisher or secret interface | ## Workflow reference @@ -1237,13 +1238,13 @@ no successful diagnosis. No tags, release records, channel notes or aliases are Purpose, interface and usage [.github/workflows/publish-release-recovery.yaml](.github/workflows/publish-release-recovery.yaml) -publishes the missing GitHub release for one stable tag whose original release run pushed the tag -but failed before publishing. It is disabled by default. The affected public repository calls it -from its own manually dispatched workflow on its default branch; it shares that repository's -normal release queue, so it never overlaps a release. +publishes this catalogue's missing GitHub release for one stable tag whose original release run +pushed the tag but failed before publishing. It is a repository-owned manual workflow, disabled by +default, not a reusable consumer interface. It shares this repository's normal release queue. +No production consumer adopted the former export; internal validation does not justify one. `enable-recovery` is a temporary rollout flag. `confirm-publication` is the operator's explicit -consent, and it stays when the flag is retired. Both must be true, and the caller's event must be +consent, and it stays when the flag is retired. Both must be true, and the event must be `workflow_dispatch`. Production publication is never automatic. The job builds the inspector and publisher from this catalogue's immutable source before any @@ -1255,32 +1256,17 @@ two complete release inventories, re-admits the incident and sends at most one c request with `make_latest` set to `"false"`; it never retries a write. Its JSON result is printed. An unknown outcome fails; dispatch again to reconcile through fresh reads. -```yaml -on: - workflow_dispatch: - inputs: - confirm-publication: { type: boolean, default: false } - tag: { type: string, required: true } - source-sha: { type: string, required: true } - run-id: { type: string, required: true } - run-attempt: { type: string, required: true } - producer-ref: { type: string, required: true } -permissions: {} -jobs: - recover: - uses: devantler-tech/.github/.github/workflows/publish-release-recovery.yaml@ # - permissions: {} - with: - enable-recovery: true - confirm-publication: ${{ inputs.confirm-publication }} - tag: ${{ inputs.tag }} - source-sha: ${{ inputs.source-sha }} - run-id: ${{ inputs.run-id }} - run-attempt: ${{ inputs.run-attempt }} - producer-ref: ${{ inputs.producer-ref }} - secrets: - APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} -``` +1. Diagnose the incident with [Inspect release recovery](#inspect-a-stranded-github-release), saving the + exact stable tag, full original source SHA, failed run ID/attempt and immutable producer ref. +2. Open this repository's **Actions → Publish Release Recovery → Run workflow**, selecting `main`. + Enter those original incident values, not the current main SHA or a replacement tag. +3. Leave `offline-test` false. Enable `enable-recovery` and `confirm-publication` only when you intend + to publish. The existing repository App secret is read by the workflow; never paste it into inputs. +4. Verify the run's publication readback. A retry uses the same original values; a matching published + release is a verified no-op, not a second publication. + +Other products must own their manual recovery workflow. Do not call this file as a reusable job. +Any future extraction requires real repeated production use and protected rollout evidence. #### Secrets and Inputs @@ -1294,16 +1280,37 @@ jobs: | `run-id` | Input (string) | — | Yes | Original failed release run ID | | `run-attempt` | Input (string) | — | Yes | Original failed run attempt | | `producer-ref` | Input (string) | — | Yes | Original catalogue workflow path at its full commit SHA | -| `APP_PRIVATE_KEY` | Secret | — | When enabled | App key for the two repository-scoped tokens | +| `APP_PRIVATE_KEY` | Repository secret | — | When enabled | Existing App key for the two repository-scoped tokens; not a dispatch input | Run `bash .github/tests/test-publish-release-recovery.sh` after changing the workflow. It checks the -consent, scope, immutable-source and read-before-write boundaries with negative controls, and runs -the shipped steps without credentials. It does not prove a real publication: protected consumer +consent, scope, immutable-source and read-before-write boundaries with negative controls, verifies +the complete internal offline projection, and runs the shipped steps without credentials. It does +not prove a real publication: protected consumer rollout, the App's observed push permission and the flag decision remain tracked in [#603](https://github.com/devantler-tech/.github/issues/603). +### Internal release-recovery validation + +
+Purpose, interface and usage + +[.github/workflows/release-recovery-offline-fixture.yaml](.github/workflows/release-recovery-offline-fixture.yaml) +is generated by `.github/scripts/generate-release-recovery-fixture.sh` for this repository's two +native CI controls. It retains the production workflow's complete offline build/refusal job, but +contains no publisher job or secret interface. It is not a supported reusable consumer export. +Regenerate it after changing the source; the independent parity and mutation checks reject drift. + +#### Inputs + +| Key | Type | Default | Required | Description | +| --- | --- | --- | --- | --- | +| `enable-recovery` | Input (boolean) | `false` | No | Select the offline fixture; never grants publication authority | +| `offline-test` | Input (boolean) | `false` | No | Run the real build and credential-refusal control without credentials | + +
+ ## Live admin-team audit The manual `Repository admin-team audit` workflow checks every active repository