Skip to content

Commit 58ed515

Browse files
fix(release): prepare repository-bound AGE publication (#421)
* fix(release): bind AGE publication to its repository identity * fix(docs): bind AGE verification to the release repository * chore: Apply megalinter fixes --------- Co-authored-by: botantler-1[bot] <185060876+botantler-1[bot]@users.noreply.github.com>
1 parent f190acc commit 58ed515

4 files changed

Lines changed: 232 additions & 16 deletions

File tree

‎.github/workflows/publish-age.yaml‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,28 +18,46 @@ jobs:
1818
- name: Require the owned release source
1919
env:
2020
REPOSITORY: ${{ github.repository }}
21+
REPOSITORY_ID: ${{ github.repository_id }}
2122
EVENT: ${{ github.event_name }}
2223
REF_TYPE: ${{ github.ref_type }}
2324
RELEASE_TAG: ${{ github.ref_name }}
2425
run: |
2526
set -euo pipefail
26-
[[ "$REPOSITORY" == devantler-tech/data-product-controller && "$EVENT" == push && "$REF_TYPE" == tag ]]
27-
[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]
27+
[[ "$REPOSITORY_ID" == 1350892700 && "$EVENT" == push && "$REF_TYPE" == tag ]] || exit 1
28+
case "$REPOSITORY" in
29+
devantler-tech/data-product-controller|devantler-tech/khoist) ;;
30+
*) exit 1 ;;
31+
esac
32+
[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]] || exit 1
2833
2934
- name: Resolve and require immutable signing workflow
3035
id: identity
36+
env:
37+
REPOSITORY: ${{ github.repository }}
38+
REPOSITORY_ID: ${{ github.repository_id }}
3139
run: |
3240
set -euo pipefail
3341
token=$(curl --fail --silent --show-error --max-time 10 \
3442
-H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
35-
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=sigstore" | jq -er '.value | select(type == "string" and length > 0)')
43+
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=sigstore" | jq -ser '
44+
if length == 1 and (.[0] | type == "object") and
45+
(.[0].value | type == "string" and length > 0)
46+
then .[0].value else error("OIDC response invalid") end')
3647
payload=$(printf '%s' "$token" | cut -d. -f2)
3748
case $((${#payload} % 4)) in
3849
2) payload="${payload}==" ;;
3950
3) payload="${payload}=" ;;
4051
esac
41-
workflow=$(printf '%s' "$payload" | tr '_-' '/+' | base64 -d | jq -er '.job_workflow_ref')
42-
[[ "$workflow" =~ ^devantler-tech/data-product-controller/\.github/workflows/publish-age\.yaml@[a-f0-9]{40}$ ]]
52+
claims=$(printf '%s' "$payload" | tr '_-' '/+' | base64 -d)
53+
printf '%s' "$claims" | jq -se --arg repository "$REPOSITORY" --arg id "$REPOSITORY_ID" '
54+
length == 1 and (.[0] | type == "object" and
55+
.repository == $repository and .repository_id == $id)' >/dev/null
56+
workflow=$(printf '%s' "$claims" | jq -er '.job_workflow_ref')
57+
prefix="$REPOSITORY/.github/workflows/publish-age.yaml@"
58+
[[ "$workflow" == "$prefix"* ]] || exit 1
59+
workflow_ref=${workflow#"$prefix"}
60+
[[ "$workflow_ref" =~ ^[a-f0-9]{40}$ ]] || exit 1
4361
printf 'workflow=%s\n' "$workflow" >> "$GITHUB_OUTPUT"
4462
4563
- name: Checkout released source
@@ -76,6 +94,7 @@ jobs:
7694

7795
- name: Publish, sign and read back the immutable artifact
7896
env:
97+
REPOSITORY: ${{ github.repository }}
7998
IMAGE_TAG: ${{ steps.image.outputs.tag }}
8099
SIGNING_WORKFLOW: ${{ steps.identity.outputs.workflow }}
81100
RELEASE_TAG: ${{ github.ref_name }}
@@ -91,7 +110,7 @@ jobs:
91110
docker logout ghcr.io
92111
cosign verify --certificate-identity "https://github.com/$SIGNING_WORKFLOW" \
93112
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
94-
--certificate-github-workflow-repository devantler-tech/data-product-controller \
113+
--certificate-github-workflow-repository "$REPOSITORY" \
95114
--certificate-github-workflow-ref "refs/tags/$RELEASE_TAG" \
96115
--certificate-github-workflow-sha "$SOURCE_SHA" "$digest"
97116
docker pull "$digest"

‎docs/postgresql-age-image.md‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ the release source revision and repeats the authenticated graph query after pull
1414
Readback first removes the job's registry login, requiring anonymous artifact and signature access.
1515
Both its initial build and release readback must pass before the artifact is considered delivered.
1616

17+
The checked-in publisher definition requires repository ID `1350892700` and an
18+
explicit original or KHoist repository name. Issuer-provided caller claims must
19+
match the trusted GitHub context, and the called workflow must belong to that
20+
same repository at an immutable commit. The extracted-step acceptance uses
21+
synthetic issuer responses; it does not verify real token signatures or artifact
22+
certificates. Release execution uses the immutable callee selected in
23+
`.github/workflows/cd.yaml`; controls in another source revision are not evidence
24+
that the selected callee enforces them.
25+
1726
For the first publication, set this image's package visibility to **Public** in its GitHub
1827
package settings. Container packages have their own visibility; a public source repository
1928
does not establish anonymous package access. If the verification fails because the package is
@@ -26,27 +35,35 @@ artifact; controller, manifest and chart publication is skipped. The proof must
2635
digest, tagged source and immutable publisher identity before promotion. Normal version tags
2736
continue to publish the complete release.
2837

29-
For independent verification, obtain the release tag, source commit and publisher commit from
30-
the tagged repository source. The publisher commit is the full SHA after `@` in
31-
`.github/workflows/cd.yaml`'s `publish-age` job. Require all three expected values; accepting any
32-
signer or any repository release is insufficient.
38+
For independent verification, obtain the caller repository name, release tag, source commit and
39+
publisher commit from the tagged repository source. Choose the repository name used at that
40+
release; do not substitute its current name for a historical certificate. The publisher commit
41+
is the full SHA after `@` in `.github/workflows/cd.yaml`'s `publish-age` job. Require all four
42+
expected values; accepting any signer or any repository release is insufficient. The AGE image
43+
family remains unchanged for both allowed caller names.
3344

3445
```bash
46+
set -euo pipefail
47+
: "${RELEASE_REPOSITORY:?Set the exact caller repository at the tagged release}"
3548
: "${AGE_IMAGE:?Set the published repository@sha256 digest}"
3649
: "${RELEASE_TAG:?Set the exact v-prefixed release tag}"
3750
: "${SOURCE_SHA:?Set its full source commit}"
3851
: "${PUBLISHER_SHA:?Set the full immutable publish-age workflow commit}"
39-
[[ "$AGE_IMAGE" =~ ^ghcr.io/devantler-tech/data-product-controller-postgresql-age@sha256:[a-f0-9]{64}$ ]]
40-
[[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ && "$PUBLISHER_SHA" =~ ^[a-f0-9]{40}$ ]]
41-
[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]
52+
case "$RELEASE_REPOSITORY" in
53+
devantler-tech/data-product-controller|devantler-tech/khoist) ;;
54+
*) exit 1 ;;
55+
esac
56+
[[ "$AGE_IMAGE" =~ ^ghcr.io/devantler-tech/data-product-controller-postgresql-age@sha256:[a-f0-9]{64}$ ]] || exit 1
57+
[[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ && "$PUBLISHER_SHA" =~ ^[a-f0-9]{40}$ ]] || exit 1
58+
[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]] || exit 1
4259
cosign verify \
43-
--certificate-identity "https://github.com/devantler-tech/data-product-controller/.github/workflows/publish-age.yaml@$PUBLISHER_SHA" \
60+
--certificate-identity "https://github.com/$RELEASE_REPOSITORY/.github/workflows/publish-age.yaml@$PUBLISHER_SHA" \
4461
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
45-
--certificate-github-workflow-repository devantler-tech/data-product-controller \
62+
--certificate-github-workflow-repository "$RELEASE_REPOSITORY" \
4663
--certificate-github-workflow-ref "refs/tags/$RELEASE_TAG" \
4764
--certificate-github-workflow-sha "$SOURCE_SHA" "$AGE_IMAGE"
4865
docker pull "$AGE_IMAGE"
49-
[[ $(docker image inspect "$AGE_IMAGE" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}') == "$SOURCE_SHA" ]]
66+
[[ $(docker image inspect "$AGE_IMAGE" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}') == "$SOURCE_SHA" ]] || exit 1
5067
```
5168

5269
## CloudNativePG configuration
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
#!/usr/bin/env bash
2+
# Exercise the actual pre-publication steps; only the issuer request is synthetic.
3+
set -euo pipefail
4+
repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
5+
publisher=$repo_root/.github/workflows/publish-age.yaml
6+
scratch=$(mktemp -d)
7+
trap 'rm -rf "$scratch"' EXIT
8+
mkdir "$scratch/bin"
9+
yq -r '.jobs.publish.steps[0].run' "$publisher" >"$scratch/source.sh"
10+
yq -r '.jobs.publish.steps[1].run' "$publisher" >"$scratch/identity.sh"
11+
cat >"$scratch/bin/curl" <<'ISSUER'
12+
#!/usr/bin/env bash
13+
set -euo pipefail
14+
touch "$FIXTURE_REQUEST"
15+
[[ $FIXTURE_MODE != unavailable ]] || exit 7
16+
cat "$FIXTURE_RESPONSE"
17+
if [[ $FIXTURE_MODE == partial ]]; then exit 7; fi
18+
if [[ $FIXTURE_MODE == multi ]]; then cat "$FIXTURE_RESPONSE"; fi
19+
ISSUER
20+
chmod +x "$scratch/bin/curl"
21+
pin=0123456789abcdef0123456789abcdef01234567
22+
23+
# claims creates public synthetic issuer data, never a real credential.
24+
claims() {
25+
jq -cn --arg repository "$1" --arg workflow "$1/.github/workflows/publish-age.yaml@$pin" \
26+
'{repository:$repository,repository_id:"1350892700",job_workflow_ref:$workflow}'
27+
}
28+
29+
# run_case catches missing identity checks through observable failure and output boundaries.
30+
run_case() {
31+
local wanted=$1 label=$2 repository=$3 id=$4 body=$5 mode=${6:-success}
32+
local event=${7:-push} ref_type=${8:-tag} tag=${9:-v5.0.0} result=0 encoded expected
33+
encoded=$(printf '%s' "$body" | base64 | tr -d '\r\n=' | tr '/+' '_-')
34+
jq -cn --arg value "fixture.$encoded.fixture" '{value:$value}' >"$scratch/response"
35+
: >"$scratch/output"
36+
rm -f "$scratch/request"
37+
# The nested shell expands its own positional arguments, not this shell's.
38+
# shellcheck disable=SC2016
39+
env -i PATH="$scratch/bin:$PATH" REPOSITORY="$repository" REPOSITORY_ID="$id" \
40+
EVENT="$event" REF_TYPE="$ref_type" RELEASE_TAG="$tag" GITHUB_OUTPUT="$scratch/output" \
41+
ACTIONS_ID_TOKEN_REQUEST_URL='https://issuer.example.test/token?fixture=1' \
42+
ACTIONS_ID_TOKEN_REQUEST_TOKEN=synthetic FIXTURE_REQUEST="$scratch/request" \
43+
FIXTURE_RESPONSE="$scratch/response" FIXTURE_MODE="$mode" \
44+
/bin/bash -c '/bin/bash "$1" && /bin/bash "$2"' fixture \
45+
"$scratch/source.sh" "$scratch/identity.sh" >"$scratch/stdout" 2>"$scratch/stderr" || result=$?
46+
if [[ $wanted == accept ]]; then
47+
expected="workflow=$repository/.github/workflows/publish-age.yaml@$pin"
48+
[[ $result == 0 && $(cat "$scratch/output") == "$expected" && -f $scratch/request ]] || {
49+
cat "$scratch/stderr" >&2
50+
echo "AGE identity positive control failed: $label" >&2
51+
exit 1
52+
}
53+
else
54+
[[ $result != 0 && $result != 124 && $result != 127 && ! -s $scratch/output ]] || {
55+
echo "AGE identity accepted or published an invalid identity: $label (exit $result)" >&2
56+
exit 1
57+
}
58+
if [[ $wanted == source ]]; then
59+
[[ ! -e $scratch/request ]] || {
60+
echo "AGE identity contacted issuer before rejecting caller: $label" >&2
61+
exit 1
62+
}
63+
fi
64+
fi
65+
}
66+
67+
original=devantler-tech/data-product-controller
68+
canonical=devantler-tech/khoist
69+
original_claims=$(claims "$original")
70+
# This must fail against the original name-only admission, after a real positive control.
71+
run_case accept current "$original" 1350892700 "$original_claims"
72+
run_case source reused-original-name "$original" 1 "$original_claims"
73+
run_case source missing-context-id "$original" '' "$original_claims"
74+
run_case source fork example/data-product-controller 1 "$original_claims"
75+
run_case source non-release "$original" 1350892700 "$original_claims" success workflow_dispatch
76+
run_case source branch "$original" 1350892700 "$original_claims" success push branch
77+
run_case source invalid-tag "$original" 1350892700 "$original_claims" success push tag main
78+
for repository in "$original" "$canonical"; do
79+
valid=$(claims "$repository")
80+
run_case accept "$repository" "$repository" 1350892700 "$valid"
81+
run_case accept proof-tag "$repository" 1350892700 "$valid" success push tag v0.0.0-age-proof.fixture
82+
run_case source reused-name "$repository" 1 "$valid"
83+
for change in \
84+
'.repository_id="1"' 'del(.repository_id)' '.repository_id=1350892700' \
85+
'.repository_id=[]' '.repository="example/fork"' \
86+
'.job_workflow_ref="example/fork/.github/workflows/publish-age.yaml@0123456789abcdef0123456789abcdef01234567"' \
87+
'.job_workflow_ref |= sub("@.*$"; "@refs/heads/main")' \
88+
'.job_workflow_ref |= sub("@.*$"; "@v5.0.0")' \
89+
'.job_workflow_ref |= sub("@.*$"; "@0123456")' \
90+
'del(.job_workflow_ref)'; do
91+
changed=$(printf '%s' "$valid" | jq -c "$change")
92+
run_case closed "$change" "$repository" 1350892700 "$changed"
93+
done
94+
other=$original
95+
if [[ $repository == "$original" ]]; then other=$canonical; fi
96+
changed=$(printf '%s' "$valid" | jq -c --arg other "$other" '.repository=$other')
97+
run_case closed cross-name-caller "$repository" 1350892700 "$changed"
98+
changed=$(printf '%s' "$valid" | jq -c --arg other "$other" \
99+
'.job_workflow_ref=$other+"/.github/workflows/publish-age.yaml@0123456789abcdef0123456789abcdef01234567"')
100+
run_case closed cross-name-callee "$repository" 1350892700 "$changed"
101+
for mode in unavailable partial multi; do
102+
run_case closed "$mode-issuer-response" "$repository" 1350892700 "$valid" "$mode"
103+
done
104+
run_case closed malformed-claims "$repository" 1350892700 '{'
105+
run_case closed non-object-claims "$repository" 1350892700 '[]'
106+
done
107+
108+
# Guard execution is useful only when GitHub supplies its trusted context at both boundaries.
109+
for step in 0 1; do
110+
# GitHub expressions must remain literal when inspecting workflow wiring.
111+
# shellcheck disable=SC2016
112+
STEP=$step yq -e '.jobs.publish.steps[env(STEP)].env.REPOSITORY == "${{ github.repository }}" and
113+
.jobs.publish.steps[env(STEP)].env.REPOSITORY_ID == "${{ github.repository_id }}"' "$publisher" >/dev/null
114+
done
115+
116+
# Run the documented independent command through rejecting signer/registry boundaries.
117+
awk '/^```bash$/{copy=1; next} copy && /^```$/{exit} copy{print}' \
118+
"$repo_root/docs/postgresql-age-image.md" >"$scratch/verify.sh"
119+
cat >"$scratch/bin/cosign" <<'VERIFIER'
120+
#!/usr/bin/env bash
121+
set -euo pipefail
122+
touch "$FIXTURE_SIGNER"
123+
printf '%s\n' "$@" | cmp - "$FIXTURE_EXPECTED"
124+
touch "$FIXTURE_VERIFIED"
125+
VERIFIER
126+
cat >"$scratch/bin/docker" <<'REGISTRY'
127+
#!/usr/bin/env bash
128+
set -euo pipefail
129+
touch "$FIXTURE_REGISTRY"
130+
if [[ $1 == pull && $2 == "$AGE_IMAGE" && $# == 2 ]]; then exit 0; fi
131+
if [[ $1 == image && $2 == inspect && $3 == "$AGE_IMAGE" && $# == 5 ]]; then
132+
printf '%s\n' "$SOURCE_SHA"
133+
exit 0
134+
fi
135+
exit 1
136+
REGISTRY
137+
chmod +x "$scratch/bin/cosign" "$scratch/bin/docker"
138+
139+
# verify_example binds the exact documented filters; mixed identities must stop before a pull.
140+
verify_example() {
141+
local wanted=$1 repository=$2 expected_repository=$3 result=0
142+
local image
143+
image="ghcr.io/devantler-tech/data-product-controller-postgresql-age@sha256:$(printf '%064d' 0)"
144+
printf '%s\n' verify \
145+
--certificate-identity "https://github.com/$expected_repository/.github/workflows/publish-age.yaml@$pin" \
146+
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
147+
--certificate-github-workflow-repository "$expected_repository" \
148+
--certificate-github-workflow-ref refs/tags/v5.0.0 \
149+
--certificate-github-workflow-sha "$pin" "$image" >"$scratch/expected"
150+
rm -f "$scratch/signer" "$scratch/registry" "$scratch/verified"
151+
env -i PATH="$scratch/bin:$PATH" RELEASE_REPOSITORY="$repository" AGE_IMAGE="$image" \
152+
RELEASE_TAG=v5.0.0 SOURCE_SHA="$pin" PUBLISHER_SHA="$pin" \
153+
FIXTURE_SIGNER="$scratch/signer" FIXTURE_REGISTRY="$scratch/registry" \
154+
FIXTURE_VERIFIED="$scratch/verified" \
155+
FIXTURE_EXPECTED="$scratch/expected" /bin/bash "$scratch/verify.sh" \
156+
>"$scratch/stdout" 2>"$scratch/stderr" || result=$?
157+
if [[ $wanted == accept ]]; then
158+
[[ $result == 0 && -f $scratch/verified && -f $scratch/registry ]] || {
159+
echo "AGE documented verification rejected permitted repository: $repository" >&2
160+
exit 1
161+
}
162+
else
163+
[[ $result != 0 && ! -e $scratch/registry ]] || {
164+
echo "AGE documented verification continued after identity rejection: $repository" >&2
165+
exit 1
166+
}
167+
if [[ $wanted == source && -e $scratch/signer ]]; then
168+
echo "AGE documented verification contacted signer for unsupported repository" >&2
169+
exit 1
170+
fi
171+
fi
172+
}
173+
verify_example accept "$original" "$original"
174+
verify_example accept "$canonical" "$canonical"
175+
verify_example reject "$original" "$canonical"
176+
verify_example reject "$canonical" "$original"
177+
verify_example source example/fork "$original"
178+
verify_example source '' "$original"
179+
printf '%s\n' 'PASS: actual AGE publisher steps reject foreign callers and mismatched or incomplete issuer evidence without identity output'

‎scripts/age-release.test.sh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,5 @@ yq -e '.jobs.smoke.needs == "publish" and (.jobs.smoke | has("if") | not) and (.
2929
callee=$(yq '.jobs."publish-age".uses' "$cd")
3030
printf '%s\n' "$callee" | grep -Eq '^devantler-tech/data-product-controller/\.github/workflows/publish-age\.yaml@[a-f0-9]{40}$' || fail 'release must use the immutable owned publisher'
3131
yq -e '.jobs."publish-age".permissions.contents == "read" and .jobs."publish-age".permissions.packages == "write" and .jobs."publish-age".permissions."id-token" == "write"' "$cd" >/dev/null || fail 'release must grant only the required publisher permissions'
32+
bash "$repo_root/scripts/age-publisher-identity.test.sh"
3233
printf '%s\n' 'AGE release contract tests passed'

0 commit comments

Comments
 (0)