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
4 files+292−00/4 viewed
| 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" |