Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d357098187 |
+2
-72
@@ -16,15 +16,13 @@ name: CI
|
||||
# The server is Gitea 1.27, which honours `concurrency` cancellation; `permissions` and
|
||||
# `timeout-minutes` are declared so the workflow is correct wherever they are enforced.
|
||||
# Artifact actions v3 are deliberate: Gitea implements that protocol, while v4 refuses non-GitHub
|
||||
# servers. A tag job uses Gitea's automatic per-job token only after every required job succeeds.
|
||||
# servers. This workflow never publishes or mutates a release; publishing is a separate manual gate.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -156,8 +154,7 @@ jobs:
|
||||
- name: Validate the built module and its migrations
|
||||
run: pnpm validate
|
||||
|
||||
# Build the canonical pair once. Integration downloads these exact bytes, and a successful tag
|
||||
# run's release job later downloads the same artifact; neither consumer is allowed to repackage.
|
||||
# Build the canonical pair once. Integration downloads these exact bytes and may not repackage.
|
||||
package:
|
||||
name: package
|
||||
runs-on: ubuntu-latest
|
||||
@@ -254,70 +251,3 @@ jobs:
|
||||
if [ -f build/platform/scripts/ci-sweep.sh ]; then
|
||||
bash build/platform/scripts/ci-sweep.sh --job
|
||||
fi
|
||||
|
||||
# Existing Signage releases and tags are created by a person. On a v* tag, attach only the
|
||||
# package pair this run's integration consumed, plus ADR 0055's required corresponding-source
|
||||
# material. No release or tag is created here, and an existing asset is never replaced.
|
||||
release:
|
||||
name: release
|
||||
needs:
|
||||
- lint
|
||||
- typecheck
|
||||
- test
|
||||
- build
|
||||
- package
|
||||
- integration
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js at the repository's version
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Install the reviewed release tooling
|
||||
run: |
|
||||
corepack enable
|
||||
corepack install
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
- name: Receive the exact pair integration tested
|
||||
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
|
||||
with:
|
||||
name: signage-module-package
|
||||
path: build/release-assets/
|
||||
|
||||
- name: Verify tag, revision, package bytes, and public-distribution material
|
||||
env:
|
||||
EXPECTED_PACKAGE_SHA256: ${{ needs.package.outputs.sha256 }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
RELEASE_REVISION: ${{ github.sha }}
|
||||
run: |
|
||||
python3 ci/release-assets.py verify-package build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION"
|
||||
package="$(find build/release-assets -maxdepth 1 -type f -name 'signage-*-linux-x64-glibc.tar.gz')"
|
||||
pnpm exec ekklesia-corresponding-source acquire \
|
||||
--package "$package" --cache "$RUNNER_TEMP/signage-corresponding-sources"
|
||||
pnpm exec ekklesia-corresponding-source build \
|
||||
--package "$package" --cache "$RUNNER_TEMP/signage-corresponding-sources" \
|
||||
--out build/release-assets
|
||||
source="${package%.tar.gz}-corresponding-source.tar.gz"
|
||||
metadata="${package%.tar.gz}.redistribution.json"
|
||||
pnpm exec ekklesia-corresponding-source verify \
|
||||
--package "$package" --source "$source" --metadata "$metadata"
|
||||
python3 ci/release-assets.py verify build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION"
|
||||
|
||||
- name: Attach the exact tested package and its required source material
|
||||
env:
|
||||
EXPECTED_PACKAGE_SHA256: ${{ needs.package.outputs.sha256 }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
RELEASE_REVISION: ${{ github.sha }}
|
||||
run: python3 ci/release-assets.py upload build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION"
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
name: Publish Signage Release
|
||||
|
||||
# Builds, proves, and publishes one Signage release. This is deliberately separate from CI: only an
|
||||
# operator's workflow_dispatch on a selected branch can start it. The normal release ref is main.
|
||||
# Every local gate, including the full external-package integration and ADR 0055 redistribution
|
||||
# verification, finishes before the release/tag is created or an existing release is touched.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: The Signage version to publish, equal to package and module metadata.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Never cancel a publication part way, and never run two at once. A rerun safely resumes an
|
||||
# identical partial upload, but refuses a tag, release, or asset that differs from this run.
|
||||
concurrency:
|
||||
group: publish-signage-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release-gate:
|
||||
name: release gate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 75
|
||||
outputs:
|
||||
sha256: ${{ steps.canonical.outputs.sha256 }}
|
||||
env:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
EKKLESIA_CI_JOB_KEY: signage-publish-release
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
RELEASE_TAG: v${{ inputs.version }}
|
||||
RELEASE_REVISION: ${{ github.sha }}
|
||||
steps:
|
||||
- name: Check out the operator-selected revision
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js at the repository's version
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Install the reviewed build and release tooling
|
||||
run: |
|
||||
corepack enable
|
||||
corepack install
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Match Core's manual publisher: releases come only from reviewed main. The source version is
|
||||
# checked before the expensive gate, and the selected commit remains the immutable tag target.
|
||||
- name: Validate requested version and selected revision
|
||||
run: |
|
||||
[[ "$GITHUB_REF" == refs/heads/main ]] || {
|
||||
echo "publish: select main; got $GITHUB_REF" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$RELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
echo "publish: version must be X.Y.Z; got $RELEASE_VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$RELEASE_REVISION" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "$(git rev-parse HEAD)" == "$RELEASE_REVISION" ]]
|
||||
[[ -z "$(git status --porcelain)" ]]
|
||||
node --input-type=module -e '
|
||||
import { readFileSync } from "node:fs";
|
||||
const requested = process.env.RELEASE_VERSION;
|
||||
const packageVersion = JSON.parse(readFileSync("package.json", "utf8")).version;
|
||||
const moduleVersion = JSON.parse(readFileSync("ekklesia.module.json", "utf8")).version;
|
||||
if (packageVersion !== requested || moduleVersion !== requested) {
|
||||
console.error(`publish: requested ${requested}, package ${packageVersion}, module ${moduleVersion}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`publish: source metadata agrees on ${requested}`);
|
||||
'
|
||||
|
||||
# This is the only package build in the workflow. Every later step consumes this directory.
|
||||
- name: Build the canonical Module Package pair once
|
||||
id: canonical
|
||||
run: bash ci/package.sh build/release-assets
|
||||
|
||||
- name: Verify the canonical Module Package
|
||||
env:
|
||||
EXPECTED_PACKAGE_SHA256: ${{ steps.canonical.outputs.sha256 }}
|
||||
run: |
|
||||
pnpm validate
|
||||
python3 ci/release-assets.py verify-package \
|
||||
build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION"
|
||||
|
||||
- name: Fetch the pinned Ekklesia integration harness
|
||||
run: bash ci/fetch-platform.sh
|
||||
|
||||
- name: Sweep stale CI stacks from the runner
|
||||
run: bash build/platform/scripts/ci-sweep.sh --stale
|
||||
|
||||
- name: Run full integration with the exact release package
|
||||
env:
|
||||
EXPECTED_PACKAGE_SHA256: ${{ steps.canonical.outputs.sha256 }}
|
||||
run: bash ci/integration.sh "$GITHUB_WORKSPACE/build/release-assets"
|
||||
|
||||
- name: Remove this publication's CI stacks
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f build/platform/scripts/ci-sweep.sh ]; then
|
||||
bash build/platform/scripts/ci-sweep.sh --job
|
||||
fi
|
||||
|
||||
- name: Build and verify required redistribution assets
|
||||
env:
|
||||
EXPECTED_PACKAGE_SHA256: ${{ steps.canonical.outputs.sha256 }}
|
||||
run: |
|
||||
package="$(find build/release-assets -maxdepth 1 -type f -name 'signage-*-linux-x64-glibc.tar.gz')"
|
||||
pnpm exec ekklesia-corresponding-source acquire \
|
||||
--package "$package" --cache "$RUNNER_TEMP/signage-corresponding-sources"
|
||||
pnpm exec ekklesia-corresponding-source build \
|
||||
--package "$package" --cache "$RUNNER_TEMP/signage-corresponding-sources" \
|
||||
--out build/release-assets
|
||||
source="${package%.tar.gz}-corresponding-source.tar.gz"
|
||||
metadata="${package%.tar.gz}.redistribution.json"
|
||||
pnpm exec ekklesia-corresponding-source verify \
|
||||
--package "$package" --source "$source" --metadata "$metadata"
|
||||
python3 ci/release-assets.py verify \
|
||||
build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION"
|
||||
|
||||
# The write-authorized job receives only the completely proven bundle. Artifact actions v3
|
||||
# are required by Gitea; v4 refuses servers other than github.com.
|
||||
- name: Hand the proven release bundle to the publisher
|
||||
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||
with:
|
||||
name: signage-release-assets
|
||||
path: build/release-assets/
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
|
||||
publish:
|
||||
name: publish
|
||||
needs: release-gate
|
||||
if: github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
EXPECTED_PACKAGE_SHA256: ${{ needs.release-gate.outputs.sha256 }}
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
RELEASE_TAG: v${{ inputs.version }}
|
||||
RELEASE_REVISION: ${{ github.sha }}
|
||||
steps:
|
||||
# The checked-out helper is the same reviewed revision the gate proved. Credentials are not
|
||||
# persisted; the automatic token is exposed only to the final API call below.
|
||||
- name: Check out the proven revision
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Receive the proven release bundle
|
||||
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
|
||||
with:
|
||||
name: signage-release-assets
|
||||
path: build/release-assets/
|
||||
|
||||
- name: Recheck the received release bundle
|
||||
run: |
|
||||
python3 ci/release-assets.py verify \
|
||||
build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION"
|
||||
|
||||
# Only this final step can mutate Gitea. It creates v<version> at the selected commit and a
|
||||
# prerelease if absent, then uploads the already-tested bytes. A rerun resumes only identical
|
||||
# state. Every asset is read back without Authorization and checked byte for byte.
|
||||
- name: Create the release and publish the exact tested assets
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
python3 ci/release-assets.py publish \
|
||||
build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION"
|
||||
+42
-17
@@ -11,6 +11,7 @@ set -Eeuo pipefail
|
||||
|
||||
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
workflow="$root/.gitea/workflows/ci.yml"
|
||||
publish_workflow="$root/.gitea/workflows/publish-release.yml"
|
||||
failures=0
|
||||
check() {
|
||||
if eval "$1"; then
|
||||
@@ -44,30 +45,54 @@ check 'grep -Eq "^EKKLESIA_PLATFORM_REF=refs/(pull/[0-9]+/head|heads/main)$" "$r
|
||||
check 'grep -q "rev-parse refs/remotes/pinned" "$root/ci/fetch-platform.sh" && grep -q "does not resolve to EKKLESIA_PLATFORM_COMMIT" "$root/ci/fetch-platform.sh"' \
|
||||
"the fetch verifies that the pinned ref resolves to the pinned commit"
|
||||
|
||||
# The package job is the only place that builds the canonical archive. Both integration and release
|
||||
# receive the same Gitea Actions v3 artifact, and release cannot run until every required job passed.
|
||||
# Normal CI builds the canonical archive once and hands those exact bytes to integration through a
|
||||
# Gitea Actions v3 artifact. It must contain no release trigger, permission, or mutation path.
|
||||
package_job="$(awk '/^ package:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$workflow")"
|
||||
release_job="$(awk '/^ release:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$workflow")"
|
||||
check 'grep -q "tags:" "$workflow" && grep -q '\"v\*\"' "$workflow"' \
|
||||
"v tags start the gated workflow"
|
||||
check 'grep -q "pull_request:" "$workflow" && grep -q "push:" "$workflow"' \
|
||||
"normal CI still runs for pull requests and main pushes"
|
||||
check '! grep -q "workflow_dispatch:" "$workflow" && ! grep -q "tags:" "$workflow"' \
|
||||
"normal CI has no manual-publish or tag trigger"
|
||||
check '! grep -q "contents: write" "$workflow" && ! grep -q "github.token" "$workflow"' \
|
||||
"normal CI has no release-specific permission or token"
|
||||
check '! grep -q "release-assets.py publish" "$workflow" && ! grep -q "ekklesia-corresponding-source" "$workflow"' \
|
||||
"normal CI cannot publish or prepare release assets"
|
||||
check 'grep -q "bash ci/package.sh" <<<"$package_job" && grep -q "actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de" <<<"$package_job"' \
|
||||
"the package job builds and transfers one canonical artifact"
|
||||
check 'grep -q "actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b" <<<"$job" && ! grep -Eq "ci/package.sh|ekklesia-pack-module" <<<"$job"' \
|
||||
"integration downloads that artifact and never rebuilds it"
|
||||
check 'grep -q "needs.package.outputs.sha256" <<<"$job" && grep -q "GITHUB_OUTPUT" "$root/ci/package.sh"' \
|
||||
"integration binds the downloaded bytes to the package job's digest"
|
||||
check 'for required in lint typecheck test build package integration; do grep -q " - $required" <<<"$release_job" || exit 1; done' \
|
||||
"release waits for every required job"
|
||||
check 'grep -q "if: startsWith(github.ref, '\''refs/tags/v'\'')" <<<"$release_job" && grep -q "contents: write" <<<"$release_job"' \
|
||||
"only a v tag gives the release job write permission"
|
||||
check 'grep -q "actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b" <<<"$release_job" && ! grep -Eq "ci/package.sh|ekklesia-pack-module" <<<"$release_job"' \
|
||||
"release downloads the tested artifact and never rebuilds it"
|
||||
check 'grep -q "EXPECTED_PACKAGE_SHA256:.*needs.package.outputs.sha256" <<<"$release_job"' \
|
||||
"release binds its selected and uploaded bytes to the package job's digest"
|
||||
check 'grep -q "ekklesia-corresponding-source verify" <<<"$release_job" && grep -q "release-assets.py upload" <<<"$release_job"' \
|
||||
"the redistribution gate runs before the exact assets are uploaded"
|
||||
check 'grep -q "GITHUB_TOKEN:.*github.token" <<<"$release_job"' \
|
||||
"upload uses Gitea's automatic per-job token"
|
||||
|
||||
# Publishing is an explicit operator action. One job validates its dispatch input and selected ref,
|
||||
# builds the package exactly once, integration-tests that directory, prepares redistribution files,
|
||||
# and only then gives the same directory to the Gitea publisher.
|
||||
gate_job="$(awk '/^ release-gate:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$publish_workflow")"
|
||||
publish_job="$(awk '/^ publish:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$publish_workflow")"
|
||||
check 'grep -q "workflow_dispatch:" "$publish_workflow" && ! grep -q "pull_request:" "$publish_workflow" && ! grep -q "push:" "$publish_workflow"' \
|
||||
"publishing is workflow_dispatch only and cannot run for a PR or push"
|
||||
check 'grep -q "inputs:" "$publish_workflow" && grep -q " version:" "$publish_workflow" && grep -q "required: true" "$publish_workflow"' \
|
||||
"manual publishing requires an explicit version input"
|
||||
check 'grep -q "refs/heads/main" <<<"$gate_job" && grep -q "packageVersion.*requested" <<<"$gate_job" && grep -q "moduleVersion.*requested" <<<"$gate_job"' \
|
||||
"the publish gate requires selected main and validates both source versions"
|
||||
check '[[ "$(grep -c "bash ci/package.sh" "$publish_workflow")" -eq 1 ]]' \
|
||||
"the publish workflow builds the canonical Module Package exactly once"
|
||||
check 'grep -q "ci/integration.sh.*build/release-assets" <<<"$gate_job" && grep -q "name: signage-release-assets" <<<"$gate_job" && grep -q "name: signage-release-assets" <<<"$publish_job" && grep -q "release-assets.py publish" <<<"$publish_job"' \
|
||||
"integration proves the package handed unchanged to the publisher"
|
||||
check 'grep -q "EXPECTED_PACKAGE_SHA256:.*steps.canonical.outputs.sha256" <<<"$gate_job" && grep -q "EXPECTED_PACKAGE_SHA256:.*needs.release-gate.outputs.sha256" <<<"$publish_job"' \
|
||||
"the publish gate binds consumers to the canonical package digest"
|
||||
check 'grep -q "ekklesia-corresponding-source verify" <<<"$gate_job" && grep -q "needs: release-gate" <<<"$publish_job"' \
|
||||
"corresponding-source verification finishes before the publishing job"
|
||||
check 'grep -q "GITHUB_TOKEN:.*github.token" <<<"$publish_job" && grep -q "contents: write" <<<"$publish_job" && ! grep -q "contents: write" <<<"$gate_job"' \
|
||||
"only the final manual publishing job receives release write authority"
|
||||
|
||||
# The manual workflow also owns a Docker integration stack, so it follows the same cleanup rule.
|
||||
publish_stale="$(grep -n -F -- 'ci-sweep.sh --stale' <<<"$gate_job" | cut -d: -f1 || true)"
|
||||
publish_run="$(grep -n -F -- 'bash ci/integration.sh' <<<"$gate_job" | cut -d: -f1 || true)"
|
||||
publish_own="$(grep -n -F -- 'ci-sweep.sh --job' <<<"$gate_job" | cut -d: -f1 || true)"
|
||||
check '[[ -n "$publish_stale" && -n "$publish_run" ]] && ((publish_stale < publish_run))' \
|
||||
"manual publishing sweeps stale stacks before integration"
|
||||
check '[[ -n "$publish_own" && -n "$publish_run" ]] && ((publish_own > publish_run))' \
|
||||
"manual publishing always has a post-integration cleanup step"
|
||||
|
||||
((failures == 0)) || {
|
||||
echo "check-cleanup: ${failures} check(s) failed." >&2
|
||||
|
||||
+50
-15
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate and attach one tested Signage release bundle to an existing Gitea release."""
|
||||
"""Validate and publish one tested Signage release bundle through the Gitea API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -124,7 +124,8 @@ class Gitea:
|
||||
raise Refused("GITHUB_SERVER_URL, GITHUB_REPOSITORY, and GITHUB_TOKEN are required")
|
||||
|
||||
def request(self, method: str, path: str, body: bytes | None = None,
|
||||
headers: dict[str, str] | None = None, *, authenticated: bool = True) -> bytes:
|
||||
headers: dict[str, str] | None = None, *, authenticated: bool = True,
|
||||
missing_ok: bool = False) -> bytes | None:
|
||||
target = path if path.startswith("http") else f"{self.server}/api/v1/repos/{self.repository}{path}"
|
||||
request_headers = dict(headers or {})
|
||||
if authenticated:
|
||||
@@ -134,29 +135,61 @@ class Gitea:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
return response.read()
|
||||
except urllib.error.HTTPError as error:
|
||||
if missing_ok and error.code == 404:
|
||||
return None
|
||||
detail = error.read()[:300].decode("utf-8", "replace")
|
||||
raise Refused(f"{method} {path} answered {error.code}: {detail}") from error
|
||||
|
||||
def json(self, method: str, path: str) -> object:
|
||||
data = self.request(method, path)
|
||||
return json.loads(data) if data else None
|
||||
def json(self, method: str, path: str, body: object | None = None,
|
||||
*, missing_ok: bool = False) -> object:
|
||||
encoded = json.dumps(body).encode() if body is not None else None
|
||||
headers = {"Content-Type": "application/json"} if encoded is not None else None
|
||||
data = self.request(method, path, encoded, headers, missing_ok=missing_ok)
|
||||
return json.loads(data) if data is not None else None
|
||||
|
||||
def public_download(self, url: str) -> bytes:
|
||||
return self.request("GET", url, authenticated=False)
|
||||
|
||||
|
||||
def upload(directory: Path, tag: str, revision: str) -> None:
|
||||
def publish(directory: Path, tag: str, revision: str) -> None:
|
||||
# This validates every local byte before the first API request, and therefore before creating or
|
||||
# mutating a release. Corresponding-source policy verification happens in the workflow first.
|
||||
files = expected_paths(directory, tag, revision)
|
||||
gitea = Gitea()
|
||||
quoted_tag = urllib.parse.quote(tag, safe="")
|
||||
release = gitea.json("GET", f"/releases/tags/{quoted_tag}")
|
||||
tagged = gitea.json("GET", f"/tags/{quoted_tag}")
|
||||
if not isinstance(release, dict):
|
||||
raise Refused(f"release {tag} does not exist")
|
||||
if not isinstance(tagged, dict) or tagged.get("commit", {}).get("sha") != revision:
|
||||
raise Refused(f"tag {tag} does not name {revision}")
|
||||
release = gitea.json("GET", f"/releases/tags/{quoted_tag}", missing_ok=True)
|
||||
tagged = gitea.json("GET", f"/tags/{quoted_tag}", missing_ok=True)
|
||||
if tagged is not None and (
|
||||
not isinstance(tagged, dict) or tagged.get("commit", {}).get("sha") != revision
|
||||
):
|
||||
raise Refused(f"existing tag {tag} does not name {revision}")
|
||||
if release is not None and tagged is None:
|
||||
raise Refused(f"release {tag} exists without a readable tag")
|
||||
if release is None:
|
||||
release = gitea.json("POST", "/releases", {
|
||||
"tag_name": tag,
|
||||
# A full immutable commit, never a moving branch, is the release's tag target.
|
||||
"target_commitish": revision,
|
||||
"name": f"Ekklesia Signage {tag}",
|
||||
"body": (
|
||||
"Published by the manual Publish Signage Release workflow after package, "
|
||||
"external integration, and corresponding-source verification."
|
||||
),
|
||||
# Existing Signage 0.x releases use Gitea's prerelease convention.
|
||||
"draft": False,
|
||||
"prerelease": True,
|
||||
})
|
||||
if not isinstance(release, dict):
|
||||
raise Refused(f"Gitea did not return the created release {tag}")
|
||||
tagged = gitea.json("GET", f"/tags/{quoted_tag}")
|
||||
if not isinstance(tagged, dict) or tagged.get("commit", {}).get("sha") != revision:
|
||||
raise Refused(f"created tag {tag} does not name {revision}")
|
||||
elif not isinstance(release, dict):
|
||||
raise Refused(f"cannot read release {tag}")
|
||||
|
||||
release_id = release.get("id")
|
||||
if not isinstance(release_id, int):
|
||||
raise Refused(f"release {tag} has no numeric id")
|
||||
listed = gitea.json("GET", f"/releases/{release_id}/assets")
|
||||
present = {asset["name"]: asset for asset in listed or []}
|
||||
unexpected = sorted(set(present) - {path.name for path in files})
|
||||
@@ -207,8 +240,10 @@ def upload(directory: Path, tag: str, revision: str) -> None:
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
try:
|
||||
if len(argv) != 4 or argv[0] not in {"verify-package", "verify", "upload"}:
|
||||
raise Refused("usage: release-assets.py (verify-package|verify|upload) DIRECTORY TAG REVISION")
|
||||
if len(argv) != 4 or argv[0] not in {"verify-package", "verify", "publish"}:
|
||||
raise Refused(
|
||||
"usage: release-assets.py (verify-package|verify|publish) DIRECTORY TAG REVISION"
|
||||
)
|
||||
command, directory, tag, revision = argv
|
||||
selected = (package_pair(Path(directory), tag, revision)
|
||||
if command == "verify-package"
|
||||
@@ -217,7 +252,7 @@ def main(argv: list[str]) -> int:
|
||||
for path in selected:
|
||||
print(path)
|
||||
else:
|
||||
upload(Path(directory), tag, revision)
|
||||
publish(Path(directory), tag, revision)
|
||||
return 0
|
||||
except (OSError, json.JSONDecodeError, Refused) as error:
|
||||
print(f"release-assets: REFUSED: {error}", file=sys.stderr)
|
||||
|
||||
+63
-15
@@ -63,9 +63,13 @@ def release_bundle(directory: Path) -> list[Path]:
|
||||
|
||||
class FakeGitea(http.server.BaseHTTPRequestHandler):
|
||||
assets: dict[str, bytes] = {}
|
||||
release_exists = True
|
||||
tag_revision: str | None = REVISION
|
||||
api_authorizations: list[str | None] = []
|
||||
public_authorizations: list[str | None] = []
|
||||
posts = 0
|
||||
asset_posts = 0
|
||||
release_posts = 0
|
||||
release_request: dict[str, object] | None = None
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
pass
|
||||
@@ -100,9 +104,15 @@ class FakeGitea(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
self.api_authorizations.append(self.headers.get("Authorization"))
|
||||
if "/releases/tags/" in self.path:
|
||||
self.answer(200, {"id": 7})
|
||||
if self.release_exists:
|
||||
self.answer(200, {"id": 7, "tag_name": f"v{VERSION}"})
|
||||
else:
|
||||
self.answer(404, {"message": "not found"})
|
||||
elif "/tags/" in self.path:
|
||||
self.answer(200, {"commit": {"sha": REVISION}})
|
||||
if self.tag_revision is not None:
|
||||
self.answer(200, {"commit": {"sha": self.tag_revision}})
|
||||
else:
|
||||
self.answer(404, {"message": "not found"})
|
||||
elif self.path.endswith("/releases/7/assets"):
|
||||
self.answer(200, self.asset_list())
|
||||
else:
|
||||
@@ -110,10 +120,19 @@ class FakeGitea(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler's API
|
||||
self.api_authorizations.append(self.headers.get("Authorization"))
|
||||
self.__class__.posts += 1
|
||||
name = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)["name"][0]
|
||||
length = int(self.headers["Content-Length"])
|
||||
body = self.rfile.read(length)
|
||||
if self.path.endswith("/releases"):
|
||||
request = json.loads(body)
|
||||
self.__class__.release_posts += 1
|
||||
self.__class__.release_request = request
|
||||
self.__class__.release_exists = True
|
||||
self.__class__.tag_revision = request["target_commitish"]
|
||||
self.answer(201, {"id": 7, "tag_name": request["tag_name"]})
|
||||
return
|
||||
|
||||
self.__class__.asset_posts += 1
|
||||
name = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)["name"][0]
|
||||
boundary = self.headers["Content-Type"].split("boundary=", 1)[1].encode()
|
||||
payload = body.split(b"\r\n\r\n", 1)[1].rsplit(b"\r\n--" + boundary + b"--", 1)[0]
|
||||
self.assets[name] = payload
|
||||
@@ -121,11 +140,16 @@ class FakeGitea(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def fake_gitea(initial: dict[str, bytes] | None = None):
|
||||
def fake_gitea(initial: dict[str, bytes] | None = None, *, release_exists: bool = True,
|
||||
tag_revision: str | None = REVISION):
|
||||
FakeGitea.assets = dict(initial or {})
|
||||
FakeGitea.release_exists = release_exists
|
||||
FakeGitea.tag_revision = tag_revision
|
||||
FakeGitea.api_authorizations = []
|
||||
FakeGitea.public_authorizations = []
|
||||
FakeGitea.posts = 0
|
||||
FakeGitea.asset_posts = 0
|
||||
FakeGitea.release_posts = 0
|
||||
FakeGitea.release_request = None
|
||||
with socketserver.TCPServer(("127.0.0.1", 0), FakeGitea) as server:
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
@@ -174,13 +198,25 @@ class ReleaseAssetsTest(unittest.TestCase):
|
||||
with self.assertRaisesRegex(release_assets.Refused, "does not match"):
|
||||
release_assets.expected_paths(directory, f"v{VERSION}", REVISION)
|
||||
|
||||
def test_uploads_each_selected_byte_and_reads_it_back_anonymously(self) -> None:
|
||||
def test_creates_release_uploads_each_byte_and_reads_it_back_anonymously(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
expected = release_bundle(directory)
|
||||
with fake_gitea():
|
||||
release_assets.upload(directory, f"v{VERSION}", REVISION)
|
||||
self.assertEqual(FakeGitea.posts, 6)
|
||||
with fake_gitea(release_exists=False, tag_revision=None):
|
||||
release_assets.publish(directory, f"v{VERSION}", REVISION)
|
||||
self.assertEqual(FakeGitea.release_posts, 1)
|
||||
self.assertEqual(FakeGitea.release_request, {
|
||||
"tag_name": f"v{VERSION}",
|
||||
"target_commitish": REVISION,
|
||||
"name": f"Ekklesia Signage v{VERSION}",
|
||||
"body": (
|
||||
"Published by the manual Publish Signage Release workflow after package, "
|
||||
"external integration, and corresponding-source verification."
|
||||
),
|
||||
"draft": False,
|
||||
"prerelease": True,
|
||||
})
|
||||
self.assertEqual(FakeGitea.asset_posts, 6)
|
||||
self.assertEqual(FakeGitea.assets, {path.name: path.read_bytes() for path in expected})
|
||||
self.assertTrue(all(value == "token test-token" for value in FakeGitea.api_authorizations))
|
||||
self.assertEqual(FakeGitea.public_authorizations, [None] * 6)
|
||||
@@ -191,14 +227,26 @@ class ReleaseAssetsTest(unittest.TestCase):
|
||||
expected = release_bundle(directory)
|
||||
initial = {path.name: path.read_bytes() for path in expected}
|
||||
with fake_gitea(initial):
|
||||
release_assets.upload(directory, f"v{VERSION}", REVISION)
|
||||
self.assertEqual(FakeGitea.posts, 0)
|
||||
release_assets.publish(directory, f"v{VERSION}", REVISION)
|
||||
self.assertEqual(FakeGitea.release_posts, 0)
|
||||
self.assertEqual(FakeGitea.asset_posts, 0)
|
||||
|
||||
conflicting = {expected[1].name: b"different published bytes"}
|
||||
with fake_gitea(conflicting):
|
||||
with self.assertRaisesRegex(release_assets.Refused, "public .* expected"):
|
||||
release_assets.upload(directory, f"v{VERSION}", REVISION)
|
||||
self.assertEqual(FakeGitea.posts, 0)
|
||||
release_assets.publish(directory, f"v{VERSION}", REVISION)
|
||||
self.assertEqual(FakeGitea.release_posts, 0)
|
||||
self.assertEqual(FakeGitea.asset_posts, 0)
|
||||
|
||||
def test_refuses_existing_tag_at_another_revision_before_mutation(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
release_bundle(directory)
|
||||
with fake_gitea(release_exists=False, tag_revision="b" * 40):
|
||||
with self.assertRaisesRegex(release_assets.Refused, "existing tag"):
|
||||
release_assets.publish(directory, f"v{VERSION}", REVISION)
|
||||
self.assertEqual(FakeGitea.release_posts, 0)
|
||||
self.assertEqual(FakeGitea.asset_posts, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user