Skip to content

Commit

g1t has actions of its own: g1t/deploy builds the checkout a job is in and puts it on g1t.page, opening the build with the job's token, masking the build's token and the project's secrets, and running g1t-runner deploy, with the app's url, the deployment's id and its kind as outputs; g1t/detect says what such a build would find (kind, install, build and output-dir) without building anything. Both use the g1t-runner on the machine, or download the newest release from g1t.sh/downloads/runner and check it against its SHA256SUMS. They live under actions/, and the Actions release workflow pushes each folder to its own repository in the g1t workspace and moves its v1 tag whenever main changes them, with the G1T_ACTIONS_PUBLISH_TOKEN secret its header describes.

Claude-Session:
https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
Claudeagent and Claudeagentcommitted Parent0087e3cBrowse files
9 files+528−10/9 viewed
+67−0
1+# Publishes g1t's own actions: each folder of actions/ (deploy, detect…)
2+# is pushed, as the whole of its own history, to the repository of the
3+# same name in the g1t workspace on g1t.sh (actions/deploy to g1t/deploy),
4+# and its major tag, v1, is moved to it. Workflows anywhere use them as
5+# `uses: g1t/deploy@v1`. A change to an action that breaks how it is
6+# used gets a new major tag here (MAJOR below), and the old one stays.
7+#
8+# Runs on a push to main that changes actions/, and by hand.
9+#
10+# Needs the repository secret G1T_ACTIONS_PUBLISH_TOKEN: an access token of
11+# the g1t workspace with contents: write on its action repositories
12+# (g1t/deploy, g1t/detect, and one for each new folder, made public
13+# before its first release). The job's own token reaches this repository
14+# only, so it cannot push there.
15+name: Actions release
16+
17+on:
18+ push:
19+ branches: [main]
20+ paths:
21+ - actions/**
22+ workflow_dispatch:
23+
24+permissions:
25+ contents: read
26+
27+concurrency:
28+ group: actions-release
29+ cancel-in-progress: false
30+
31+env:
32+ MAJOR: v1
33+ NAMESPACE: g1t
34+
35+jobs:
36+ publish:
37+ name: Publish g1t/<action>
38+ runs-on: ubuntu-latest
39+ timeout-minutes: 15
40+ steps:
41+ - uses: actions/checkout@v5
42+ with:
43+ fetch-depth: 0
44+ - name: Push each action and move its major tag
45+ env:
46+ PUBLISH_TOKEN: ${{ secrets.G1T_ACTIONS_PUBLISH_TOKEN }}
47+ SERVER: ${{ github.server_url }}
48+ run: |
49+ set -euo pipefail
50+ if [ -z "${PUBLISH_TOKEN}" ]; then
51+ echo "::error::Set the repository secret G1T_ACTIONS_PUBLISH_TOKEN (this workflow's header says what it needs)."
52+ exit 1
53+ fi
54+ echo "::add-mask::${PUBLISH_TOKEN}"
55+ auth="Authorization: Basic $(printf 'g1t:%s' "${PUBLISH_TOKEN}" | base64 -w0)"
56+ git config user.name g1t
57+ git config user.email g1t@users.noreply.g1t.sh
58+ for dir in actions/*/; do
59+ name="$(basename "${dir}")"
60+ [ -f "${dir}action.yml" ] || { echo "::warning::${dir} has no action.yml; skipped."; continue; }
61+ commit="$(git subtree split --prefix "actions/${name}" HEAD)"
62+ remote="${SERVER}/${NAMESPACE}/${name}.git"
63+ echo "Publishing actions/${name} (${commit}) to ${NAMESPACE}/${name}"
64+ git -c http.extraHeader="${auth}" push "${remote}" "${commit}:refs/heads/main"
65+ git -c http.extraHeader="${auth}" push --force "${remote}" "${commit}:refs/tags/${MAJOR}"
66+ echo "- \`${NAMESPACE}/${name}@${MAJOR}\` is ${commit}" >> "${GITHUB_STEP_SUMMARY}"
67+ done
+75−0
1+# g1t/deploy
2+
3+Builds the checkout a g1t Actions job is in and puts it on
4+[g1t.page](https://docs.g1t.sh/guides/deployments/): production when the
5+run is on the project's production branch, the pull request's preview for
6+a pull request's run, and the branch's preview for a run on any other
7+branch.
8+
9+```yaml
10+name: Deploy
11+
12+on:
13+ push:
14+ branches: [main]
15+ pull_request:
16+
17+permissions:
18+ contents: read
19+ deployments: write
20+
21+jobs:
22+ deploy:
23+ runs-on: ubuntu-latest
24+ steps:
25+ - uses: actions/checkout@v5
26+ - id: deploy
27+ uses: g1t/deploy@v1
28+ - run: echo "Live at ${{ steps.deploy.outputs.url }}"
29+```
30+
31+The project must have Deployments on, and the workspace's plan must
32+include them. The guide,
33+[Deploying from a workflow](https://docs.g1t.sh/guides/deployments/#deploying-from-a-workflow),
34+says what is built and when.
35+
36+## Inputs
37+
38+| Input | Default | |
39+| --- | --- | --- |
40+| `project` | The repository's one project with Deployments on | The project's slug. Needed when the repository has several. |
41+| `root-dir` | The project's setting | Where the project is in the checkout. |
42+| `build-command` | The project's setting, else what the build finds | What builds it. |
43+| `output-dir` | The project's setting, else the first of `dist`, `build`, `out`, `public`, `_site`, `.output/public` | The folder a static build writes. |
44+| `sha` | The run's commit | The commit this is a build of, for its `g1t / deploy` check. |
45+| `token` | `${{ github.token }}` | The job's own token. It needs `deployments: write`; any other token is refused. |
46+
47+## Outputs
48+
49+| Output | |
50+| --- | --- |
51+| `url` | Where the app is served. |
52+| `deployment-id` | The build's id, `dpl_…`. |
53+| `kind` | `production` or `preview`. |
54+
55+## How it works
56+
57+1. It calls `POST /repos/{owner}/{repo}/deployments/builds` with the job's
58+ token. g1t checks the plan and the spend limit, decides production or
59+ preview from the run, sets the commit's `g1t / deploy` check to pending,
60+ and answers with a token for this build alone and the project's
61+ variables and secrets for builds of that kind.
62+2. It masks that token and every secret with `::add-mask::`.
63+3. It runs `g1t-runner deploy` in the checkout, which installs, builds and
64+ uploads the app exactly as g1t's own builds do, and reports to g1t with
65+ the build's token. The build's log is in the step's.
66+
67+A pull request from a fork or from someone without the Write role, and a
68+run that had to be approved, build without the project's secrets. The
69+build's time is the job's Actions time; it is not charged again.
70+
71+`g1t-runner` is on g1t's machines. Elsewhere (a self-hosted runner), the
72+action uses the one on `PATH`, or downloads the newest release from
73+`https://g1t.sh/downloads/runner/latest/` and checks it against the
74+release's `SHA256SUMS`. It runs on Linux and macOS, with `curl` and either
75+`jq` or `python3`.
+54−0
1+name: Deploy to g1t.page
2+description: Builds this checkout and puts it on g1t.page. Production from the project's production branch, a preview from a pull request or any other branch.
3+author: g1t
4+
5+inputs:
6+ project:
7+ description: The project's slug. Leave it out when the repository has one project with Deployments on.
8+ required: false
9+ default: ""
10+ root-dir:
11+ description: Where the project is in the checkout. The project's own setting unless you say.
12+ required: false
13+ default: ""
14+ build-command:
15+ description: What builds it. The project's own setting, else what the build finds (a Workers config, or the package's build script).
16+ required: false
17+ default: ""
18+ output-dir:
19+ description: The folder a static build writes. The project's own setting, else the first of dist, build, out, public, _site or .output/public.
20+ required: false
21+ default: ""
22+ sha:
23+ description: The commit this is a build of, for the commit's g1t / deploy check. The run's commit unless you say.
24+ required: false
25+ default: ""
26+ token:
27+ description: The job's own token, which needs permissions deployments write. Another token is refused.
28+ required: false
29+ default: ${{ github.token }}
30+
31+outputs:
32+ url:
33+ description: Where the app is served, on g1t.page.
34+ value: ${{ steps.deploy.outputs.url }}
35+ deployment-id:
36+ description: The build's id, dpl_….
37+ value: ${{ steps.deploy.outputs.deployment-id }}
38+ kind:
39+ description: production or preview.
40+ value: ${{ steps.deploy.outputs.kind }}
41+
42+runs:
43+ using: composite
44+ steps:
45+ - id: deploy
46+ shell: bash
47+ env:
48+ INPUT_PROJECT: ${{ inputs.project }}
49+ INPUT_ROOT_DIR: ${{ inputs.root-dir }}
50+ INPUT_BUILD_COMMAND: ${{ inputs.build-command }}
51+ INPUT_OUTPUT_DIR: ${{ inputs.output-dir }}
52+ INPUT_SHA: ${{ inputs.sha }}
53+ INPUT_TOKEN: ${{ inputs.token }}
54+ run: bash "$GITHUB_ACTION_PATH/deploy.sh"
+109−0
1+#!/usr/bin/env bash
2+# The g1t/deploy action's work (see README.md): opens a g1t.page build for
3+# this job with POST /repos/{owner}/{repo}/deployments/builds, masks its
4+# token and the project's build secrets, and runs `g1t-runner deploy` in
5+# the checkout with them. Reads the action's inputs as INPUT_* variables.
6+set -euo pipefail
7+
8+api="${GITHUB_API_URL:-https://api.g1t.sh}"
9+api="${api%/}"
10+repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is not set: run this in a g1t Actions job.}"
11+if [ -z "${INPUT_TOKEN:-}" ]; then
12+ echo "::error::The token input is empty: give the job's own token, \${{ github.token }}, with permissions: deployments: write."
13+ exit 1
14+fi
15+
16+# JSON, with jq where there is one, else Python.
17+if command -v jq >/dev/null 2>&1; then
18+ field() { jq -r --arg key "$1" '.[$key] // empty | if type == "object" or type == "array" then tojson else tostring end'; }
19+ values() { jq -r --arg key "$1" '.[$key] // {} | .[] | tostring'; }
20+ message() { jq -r '.message // .error.message // empty' 2>/dev/null || true; }
21+ body() { jq -cn --arg project "$1" --arg sha "$2" '{project: $project, sha: $sha} | with_entries(select(.value != ""))'; }
22+elif command -v python3 >/dev/null 2>&1; then
23+ field() {
24+ python3 -c 'import json, sys
25+v = json.load(sys.stdin).get(sys.argv[1])
26+if v is None: sys.exit(0)
27+print(json.dumps(v, separators=(",", ":")) if isinstance(v, (dict, list)) else str(v).lower() if isinstance(v, bool) else v)' "$1"
28+ }
29+ values() { python3 -c 'import json, sys
30+for v in (json.load(sys.stdin).get(sys.argv[1]) or {}).values(): print(v)' "$1"; }
31+ message() { python3 -c 'import json, sys
32+d = json.load(sys.stdin); print(d.get("message") or (d.get("error") or {}).get("message") or "")' 2>/dev/null || true; }
33+ body() { python3 -c 'import json, sys
34+print(json.dumps({k: v for k, v in (("project", sys.argv[1]), ("sha", sys.argv[2])) if v}))' "$1" "$2"; }
35+else
36+ echo "::error::g1t/deploy needs jq or python3 on the machine."
37+ exit 1
38+fi
39+
40+# The build, opened for this job.
41+request="$(body "${INPUT_PROJECT:-}" "${INPUT_SHA:-}")"
42+answer_file="$(mktemp)"
43+trap 'rm -f "${answer_file}"' EXIT
44+status="$(curl -sS -o "${answer_file}" -w '%{http_code}' -X POST \
45+ -H "Authorization: Bearer ${INPUT_TOKEN}" \
46+ -H "Content-Type: application/json" \
47+ -H "Accept: application/json" \
48+ --data "${request}" \
49+ "${api}/repos/${repo}/deployments/builds")" || {
50+ echo "::error::Could not reach ${api} to open the build."
51+ exit 1
52+}
53+answer="$(cat "${answer_file}")"
54+if [ "${status}" != "200" ]; then
55+ why="$(message <<<"${answer}")"
56+ echo "::error::g1t did not open the build (${status}): ${why:-no reason given}"
57+ exit 1
58+fi
59+
60+# Nothing secret is printed before it is masked.
61+build_token="$(field token <<<"${answer}")"
62+echo "::add-mask::${build_token}"
63+while IFS= read -r secret; do
64+ [ -n "${secret}" ] && echo "::add-mask::${secret}"
65+done < <(values build_secrets <<<"${answer}")
66+
67+deployment_id="$(field deploy_id <<<"${answer}")"
68+kind="$(field kind <<<"${answer}")"
69+url="$(field url <<<"${answer}")"
70+project="$(field project <<<"${answer}")"
71+branch="$(field branch <<<"${answer}")"
72+trusted="$(field trusted <<<"${answer}")"
73+{
74+ echo "deployment-id=${deployment_id}"
75+ echo "kind=${kind}"
76+ echo "url=${url}"
77+} >>"${GITHUB_OUTPUT:-/dev/null}"
78+
79+if [ "${kind}" = "production" ]; then
80+ echo "Building ${project} for production, at ${url}"
81+else
82+ echo "Building the ${branch} preview of ${project}, at ${url}"
83+fi
84+if [ "${trusted}" != "true" ]; then
85+ echo "::notice::This run is not trusted with the project's build secrets (a pull request from outside, or a run that needed approval): it builds without them."
86+fi
87+
88+# What the step gives wins over the project's own settings.
89+pick() { if [ -n "$1" ]; then echo "$1"; else field "$2" <<<"${answer}"; fi; }
90+export DEPLOY_ID="${deployment_id}"
91+export DEPLOY_TOKEN="${build_token}"
92+export G1T_API="${api}"
93+export ROOT_DIR="$(pick "${INPUT_ROOT_DIR:-}" root_dir)"
94+export BUILD_COMMAND="$(pick "${INPUT_BUILD_COMMAND:-}" build_command)"
95+export OUTPUT_DIR="$(pick "${INPUT_OUTPUT_DIR:-}" output_dir)"
96+export BUILD_ENV="$(field build_env <<<"${answer}")"
97+export BUILD_SECRETS="$(field build_secrets <<<"${answer}")"
98+unset GIT_REMOTE
99+
100+runner="$(bash "${GITHUB_ACTION_PATH:-$(dirname "$0")}/runner.sh")"
101+if ! "${runner}" deploy --help 2>/dev/null | grep -q DEPLOY_ID; then
102+ echo "::error::${runner} is older than g1t/deploy needs: update it (g1t-runner update), or let the action download it by taking it off PATH."
103+ exit 1
104+fi
105+"${runner}" deploy
106+echo "Deployed: ${url}"
107+if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
108+ echo "Deployed ${project} (${kind}) to ${url}" >>"${GITHUB_STEP_SUMMARY}"
109+fi
+54−0
1+#!/usr/bin/env bash
2+# Finds g1t-runner for the g1t/deploy and g1t/detect actions and prints its
3+# path: G1T_RUNNER if it names one, else the one on PATH (g1t's machines
4+# have it at /usr/local/bin/g1t-runner), else the newest release from
5+# g1t.sh/downloads/runner, checked against that release's SHA256SUMS, as
6+# docs.g1t.sh/guides/self-hosted-runners/ downloads it.
7+#
8+# actions/deploy/runner.sh and actions/detect/runner.sh are the same file:
9+# each action is published as a repository of its own.
10+set -euo pipefail
11+
12+if [ -n "${G1T_RUNNER:-}" ] && [ -x "${G1T_RUNNER}" ]; then
13+ echo "${G1T_RUNNER}"
14+ exit 0
15+fi
16+if found="$(command -v g1t-runner 2>/dev/null)"; then
17+ echo "${found}"
18+ exit 0
19+fi
20+
21+case "$(uname -s)-$(uname -m)" in
22+ Linux-x86_64 | Linux-amd64) platform=linux-x64 ;;
23+ Linux-aarch64 | Linux-arm64) platform=linux-arm64 ;;
24+ Darwin-arm64) platform=macos-arm64 ;;
25+ Darwin-x86_64) platform=macos-x64 ;;
26+ *)
27+ echo "::error::g1t-runner is not on PATH, and g1t has no release of it for $(uname -s) $(uname -m)." >&2
28+ exit 1
29+ ;;
30+esac
31+
32+downloads="${G1T_RUNNER_DOWNLOADS:-https://g1t.sh/downloads/runner/latest}"
33+dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/g1t-runner-${platform}"
34+file="g1t-runner-${platform}"
35+if [ ! -x "${dir}/g1t-runner" ]; then
36+ mkdir -p "${dir}"
37+ echo "Downloading ${file} from ${downloads}" >&2
38+ curl -fsSL --retry 3 -o "${dir}/${file}" "${downloads}/${file}"
39+ curl -fsSL --retry 3 -o "${dir}/SHA256SUMS" "${downloads}/SHA256SUMS"
40+ expected="$(awk -v name="${file}" '$2 == name { print $1 }' "${dir}/SHA256SUMS")"
41+ if command -v sha256sum >/dev/null 2>&1; then
42+ actual="$(sha256sum "${dir}/${file}" | awk '{ print $1 }')"
43+ else
44+ actual="$(shasum -a 256 "${dir}/${file}" | awk '{ print $1 }')"
45+ fi
46+ if [ -z "${expected}" ] || [ "${expected}" != "${actual}" ]; then
47+ echo "::error::The downloaded ${file} does not match the release's SHA256SUMS." >&2
48+ rm -f "${dir}/${file}"
49+ exit 1
50+ fi
51+ chmod +x "${dir}/${file}"
52+ mv "${dir}/${file}" "${dir}/g1t-runner"
53+fi
54+echo "${dir}/g1t-runner"
+35−0
1+# g1t/detect
2+
3+Says what a [g1t.page](https://docs.g1t.sh/guides/deployments/) build of
4+the checkout would find, without building or deploying anything: the
5+same rules [g1t/deploy](https://g1t.sh/g1t/deploy) builds with.
6+
7+```yaml
8+steps:
9+ - uses: actions/checkout@v5
10+ - id: detect
11+ uses: g1t/detect@v1
12+ - if: steps.detect.outputs.kind == 'none'
13+ run: echo "::error::Nothing here would be served."
14+ - if: steps.detect.outputs.install != ''
15+ run: ${{ steps.detect.outputs.install }}
16+```
17+
18+## Inputs
19+
20+| Input | Default | |
21+| --- | --- | --- |
22+| `root-dir` | The checkout | Where the project is in the checkout. |
23+| `build-command` | None | A build command of your own, as `g1t/deploy` takes it. |
24+| `output-dir` | None | An output folder of your own, as `g1t/deploy` takes it. |
25+
26+## Outputs
27+
28+| Output | |
29+| --- | --- |
30+| `kind` | `workers` (a `wrangler.jsonc`, `wrangler.json` or `wrangler.toml`), `static` (a site a build writes), `html` (the files as they are) or `none`. |
31+| `install` | How dependencies are installed, from the lockfile: `npm ci`, `pnpm install --frozen-lockfile`, and so on. Empty without a `package.json`. |
32+| `build` | What builds it: your command, `npm run build` for a package with a build script, or `wrangler deploy --dry-run` for Workers. Empty when nothing does. |
33+| `output-dir` | The folder served, relative to the project (`.` for the project itself): a Workers project's assets directory, or the first of `dist`, `build`, `out`, `public`, `_site`, `.output/public` that exists. Empty when a build has yet to write it. |
34+
35+It runs `g1t-runner detect`, found or downloaded as `g1t/deploy` finds it.
+47−0
1+name: Detect a g1t.page project
2+description: Says what a g1t.page build of this checkout would find, without building anything. Its kind, how it installs, what builds it and where its output is.
3+author: g1t
4+
5+inputs:
6+ root-dir:
7+ description: Where the project is in the checkout. The checkout itself unless you say.
8+ required: false
9+ default: ""
10+ build-command:
11+ description: A build command of your own, as g1t/deploy takes it.
12+ required: false
13+ default: ""
14+ output-dir:
15+ description: An output folder of your own, as g1t/deploy takes it.
16+ required: false
17+ default: ""
18+
19+outputs:
20+ kind:
21+ description: workers (a Wrangler config), static (a site a build writes), html (the files as they are) or none.
22+ value: ${{ steps.detect.outputs.kind }}
23+ install:
24+ description: How its dependencies are installed, such as npm ci. Empty without a package.json.
25+ value: ${{ steps.detect.outputs.install }}
26+ build:
27+ description: What builds it. Empty when nothing does.
28+ value: ${{ steps.detect.outputs.build }}
29+ output-dir:
30+ description: The folder that is served, relative to the project (. for the project itself). Empty when a build has yet to write it.
31+ value: ${{ steps.detect.outputs.output-dir }}
32+
33+runs:
34+ using: composite
35+ steps:
36+ - id: detect
37+ shell: bash
38+ env:
39+ ROOT_DIR: ${{ inputs.root-dir }}
40+ BUILD_COMMAND: ${{ inputs.build-command }}
41+ OUTPUT_DIR: ${{ inputs.output-dir }}
42+ run: |
43+ set -euo pipefail
44+ runner="$(bash "$GITHUB_ACTION_PATH/runner.sh")"
45+ found="$("$runner" detect)"
46+ echo "$found"
47+ echo "$found" >> "$GITHUB_OUTPUT"
+54−0
1+#!/usr/bin/env bash
2+# Finds g1t-runner for the g1t/deploy and g1t/detect actions and prints its
3+# path: G1T_RUNNER if it names one, else the one on PATH (g1t's machines
4+# have it at /usr/local/bin/g1t-runner), else the newest release from
5+# g1t.sh/downloads/runner, checked against that release's SHA256SUMS, as
6+# docs.g1t.sh/guides/self-hosted-runners/ downloads it.
7+#
8+# actions/deploy/runner.sh and actions/detect/runner.sh are the same file:
9+# each action is published as a repository of its own.
10+set -euo pipefail
11+
12+if [ -n "${G1T_RUNNER:-}" ] && [ -x "${G1T_RUNNER}" ]; then
13+ echo "${G1T_RUNNER}"
14+ exit 0
15+fi
16+if found="$(command -v g1t-runner 2>/dev/null)"; then
17+ echo "${found}"
18+ exit 0
19+fi
20+
21+case "$(uname -s)-$(uname -m)" in
22+ Linux-x86_64 | Linux-amd64) platform=linux-x64 ;;
23+ Linux-aarch64 | Linux-arm64) platform=linux-arm64 ;;
24+ Darwin-arm64) platform=macos-arm64 ;;
25+ Darwin-x86_64) platform=macos-x64 ;;
26+ *)
27+ echo "::error::g1t-runner is not on PATH, and g1t has no release of it for $(uname -s) $(uname -m)." >&2
28+ exit 1
29+ ;;
30+esac
31+
32+downloads="${G1T_RUNNER_DOWNLOADS:-https://g1t.sh/downloads/runner/latest}"
33+dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/g1t-runner-${platform}"
34+file="g1t-runner-${platform}"
35+if [ ! -x "${dir}/g1t-runner" ]; then
36+ mkdir -p "${dir}"
37+ echo "Downloading ${file} from ${downloads}" >&2
38+ curl -fsSL --retry 3 -o "${dir}/${file}" "${downloads}/${file}"
39+ curl -fsSL --retry 3 -o "${dir}/SHA256SUMS" "${downloads}/SHA256SUMS"
40+ expected="$(awk -v name="${file}" '$2 == name { print $1 }' "${dir}/SHA256SUMS")"
41+ if command -v sha256sum >/dev/null 2>&1; then
42+ actual="$(sha256sum "${dir}/${file}" | awk '{ print $1 }')"
43+ else
44+ actual="$(shasum -a 256 "${dir}/${file}" | awk '{ print $1 }')"
45+ fi
46+ if [ -z "${expected}" ] || [ "${expected}" != "${actual}" ]; then
47+ echo "::error::The downloaded ${file} does not match the release's SHA256SUMS." >&2
48+ rm -f "${dir}/${file}"
49+ exit 1
50+ fi
51+ chmod +x "${dir}/${file}"
52+ mv "${dir}/${file}" "${dir}/g1t-runner"
53+fi
54+echo "${dir}/g1t-runner"
+33−1
2828 job.permissions(&workflow, TokenDefault::Restricted)
2929 };
3030 // CI and Deploy only read: nothing they do writes with the token.
31− for (name, id) in [("ci.yml", "rust"), ("deploy.yml", "core"), ("runner-release.yml", "binaries")] {
31+ // The actions release pushes with its own secret, never the job's token.
32+ for (name, id) in [("ci.yml", "rust"), ("deploy.yml", "core"), ("runner-release.yml", "binaries"), ("actions-release.yml", "publish")] {
3233 let permissions = job(name, id);
3334 assert!(permissions.listed().iter().all(|(_, access)| *access <= Access::Read), "{name} {id}");
3435 }
297298 }
298299
299300 #[test]
301+fn g1ts_own_actions_are_published_when_they_change() {
302+ let release = read("actions-release.yml");
303+ let push = release.trigger("push").unwrap();
304+ assert!(push.branches.allows("main"));
305+ assert!(!push.branches.allows("feature"));
306+ assert!(release.trigger("workflow_dispatch").is_some());
307+ let source = std::fs::read_to_string(workflows_dir().join("actions-release.yml")).unwrap();
308+ assert!(source.contains("actions/**"));
309+ assert!(source.contains("secrets.G1T_ACTIONS_PUBLISH_TOKEN"));
310+ // Every action is a composite one with its README, and the copies of
311+ // the script that finds g1t-runner are the same file.
312+ let actions = workflows_dir().join("../../actions");
313+ let mut finders = Vec::new();
314+ for entry in std::fs::read_dir(&actions).unwrap() {
315+ let dir = entry.unwrap().path();
316+ let manifest = std::fs::read_to_string(dir.join("action.yml")).unwrap_or_else(|_| panic!("{} has no action.yml", dir.display()));
317+ let manifest: serde_yaml::Value = serde_yaml::from_str(&manifest).unwrap();
318+ assert_eq!(manifest["runs"]["using"].as_str(), Some("composite"), "{}", dir.display());
319+ assert!(dir.join("README.md").exists(), "{}", dir.display());
320+ finders.push(std::fs::read_to_string(dir.join("runner.sh")).unwrap());
321+ }
322+ assert!(finders.len() >= 2);
323+ assert!(finders.windows(2).all(|pair| pair[0] == pair[1]), "actions/*/runner.sh differ");
324+ let deploy: serde_yaml::Value = serde_yaml::from_str(&std::fs::read_to_string(actions.join("deploy/action.yml")).unwrap()).unwrap();
325+ assert_eq!(deploy["inputs"]["token"]["default"].as_str(), Some("${{ github.token }}"));
326+ for output in ["url", "deployment-id"] {
327+ assert!(deploy["outputs"][output]["value"].is_string(), "{output}");
328+ }
329+}
330+
331+#[test]
300332 fn the_runner_base_rebuilds_weekly_on_a_machine_with_docker() {
301333 let base = read("runner-base.yml");
302334 assert!(base.trigger("schedule").is_some());