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
9 files+528−10/9 viewed
| 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 |
| 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`. |
| 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" |
| 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 |
| 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" |
| 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. |
| 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" |
| 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" |
| 28 | 28 | job.permissions(&workflow, TokenDefault::Restricted) | |
| 29 | 29 | }; | |
| 30 | 30 | // 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")] { | |
| 32 | 33 | let permissions = job(name, id); | |
| 33 | 34 | assert!(permissions.listed().iter().all(|(_, access)| *access <= Access::Read), "{name} {id}"); | |
| 34 | 35 | } | |
| ⋯ | |||
| 297 | 298 | } | |
| 298 | 299 | ||
| 299 | 300 | #[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] | |
| 300 | 332 | fn the_runner_base_rebuilds_weekly_on_a_machine_with_docker() { | |
| 301 | 333 | let base = read("runner-base.yml"); | |
| 302 | 334 | assert!(base.trigger("schedule").is_some()); | |