Merge workspace aliases through Actions and tokens: uses: g1t/deploy@v1 resolves to flagon-io/deploy, a token scoped to a repository reaches it under the alias, a g1t/ action is never taken from GitHub, and the Actions release creates g1t's action repositories when they are missing.
6 files+272−260/6 viewed
| 1 | 1 | # Publishes g1t's own actions: each folder of actions/ (deploy, detect…) | |
| 2 | 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 | |
| 3 | + | # same name under g1t/ on g1t.sh (actions/deploy to g1t/deploy), and its | |
| 4 | + | # major tag, v1, is moved to it. Workflows anywhere use them as | |
| 5 | 5 | # `uses: g1t/deploy@v1`. A change to an action that breaks how it is | |
| 6 | 6 | # used gets a new major tag here (MAJOR below), and the old one stays. | |
| 7 | 7 | # | |
| ⋯ | |||
| 9 | 9 | # way (workflows/.g1t/workflows/deploy.yml is | |
| 10 | 10 | # `g1t/workflows/.g1t/workflows/deploy.yml@v1`). | |
| 11 | 11 | # | |
| 12 | + | # `g1t` is a workspace alias: on g1t.sh it is flagon-io's, so g1t/deploy | |
| 13 | + | # is the repository flagon-io/deploy, and the API, git and `uses:` all | |
| 14 | + | # answer for it under either name (see the workspaces guide, "Workspace | |
| 15 | + | # aliases"). This workflow names the alias throughout and never needs to | |
| 16 | + | # know which workspace it leads to. | |
| 17 | + | # | |
| 18 | + | # A repository that does not exist yet is created first, public, through | |
| 19 | + | # the API (POST /repos with `"workspace": "g1t"`, which the alias runs in | |
| 20 | + | # flagon-io); one that exists but is private stops the run, as nobody | |
| 21 | + | # outside its workspace could use it. | |
| 22 | + | # | |
| 12 | 23 | # Runs on a push to main that changes actions/ or workflows/, and by hand. | |
| 13 | 24 | # | |
| 14 | 25 | # Needs the repository secret G1T_ACTIONS_PUBLISH_TOKEN: an access token of | |
| 15 | − | # the g1t workspace with contents: write on its action repositories | |
| 16 | − | # (g1t/deploy, g1t/detect, and one for each new folder, made public | |
| 17 | − | # before its first release). The job's own token reaches this repository | |
| 18 | − | # only, so it cannot push there. | |
| 26 | + | # the workspace g1t leads to (flagon-io), with the scopes repo:write (to | |
| 27 | + | # create a missing repository) and code:write (to push and move tags). | |
| 28 | + | # A token without repo:write still publishes to repositories | |
| 29 | + | # that exist, and the run says which one to create by hand. The job's own | |
| 30 | + | # token reaches this repository only, so it can neither create nor push | |
| 31 | + | # to the others. | |
| 19 | 32 | name: Actions release | |
| 20 | 33 | ||
| 21 | 34 | on: | |
| ⋯ | |||
| 50 | 63 | env: | |
| 51 | 64 | PUBLISH_TOKEN: ${{ secrets.G1T_ACTIONS_PUBLISH_TOKEN }} | |
| 52 | 65 | SERVER: ${{ github.server_url }} | |
| 66 | + | API: ${{ github.api_url }} | |
| 53 | 67 | run: | | |
| 54 | 68 | set -euo pipefail | |
| 55 | 69 | if [ -z "${PUBLISH_TOKEN}" ]; then | |
| ⋯ | |||
| 58 | 72 | fi | |
| 59 | 73 | echo "::add-mask::${PUBLISH_TOKEN}" | |
| 60 | 74 | auth="Authorization: Basic $(printf 'g1t:%s' "${PUBLISH_TOKEN}" | base64 -w0)" | |
| 75 | + | bearer="Authorization: Bearer ${PUBLISH_TOKEN}" | |
| 61 | 76 | git config user.name g1t | |
| 62 | 77 | git config user.email g1t@users.noreply.g1t.sh | |
| 78 | + | ||
| 79 | + | # The repository g1t/<name>, public: created when it is missing. | |
| 80 | + | ensure() { | |
| 81 | + | local name="$1" description="$2" found status | |
| 82 | + | found="$(mktemp)" | |
| 83 | + | status="$(curl -sS -o "${found}" -w '%{http_code}' -H "${bearer}" "${API}/repos/${NAMESPACE}/${name}")" | |
| 84 | + | case "${status}" in | |
| 85 | + | 200) | |
| 86 | + | if [ "$(jq -r '.is_private' "${found}")" != "false" ]; then | |
| 87 | + | echo "::error::$(jq -r '.namespace + "/" + .name' "${found}") is private, so no workflow outside its workspace could use ${NAMESPACE}/${name}. Make it public, then run this again." | |
| 88 | + | exit 1 | |
| 89 | + | fi | |
| 90 | + | return 0 | |
| 91 | + | ;; | |
| 92 | + | 404) ;; | |
| 93 | + | *) | |
| 94 | + | echo "::error::Looking for ${NAMESPACE}/${name} answered ${status}: $(cat "${found}")" | |
| 95 | + | exit 1 | |
| 96 | + | ;; | |
| 97 | + | esac | |
| 98 | + | echo "Creating ${NAMESPACE}/${name}, public" | |
| 99 | + | status="$(jq -n --arg workspace "${NAMESPACE}" --arg name "${name}" --arg description "${description}" \ | |
| 100 | + | '{ workspace: $workspace, name: $name, description: $description, private: false }' \ | |
| 101 | + | | curl -sS -o "${found}" -w '%{http_code}' -X POST -H "${bearer}" -H "Content-Type: application/json" \ | |
| 102 | + | --data-binary @- "${API}/repos")" | |
| 103 | + | if [ "${status}" != "201" ] && [ "${status}" != "200" ]; then | |
| 104 | + | echo "::error::Creating ${NAMESPACE}/${name} answered ${status}: $(cat "${found}"). Give G1T_ACTIONS_PUBLISH_TOKEN the repo:write scope, or create ${NAMESPACE}/${name} once by hand, public." | |
| 105 | + | exit 1 | |
| 106 | + | fi | |
| 107 | + | echo "- Created \`$(jq -r '.namespace + "/" + .name' "${found}")\` for \`${NAMESPACE}/${name}\`" >> "${GITHUB_STEP_SUMMARY}" | |
| 108 | + | } | |
| 109 | + | ||
| 63 | 110 | for dir in actions/*/; do | |
| 64 | 111 | name="$(basename "${dir}")" | |
| 65 | 112 | [ -f "${dir}action.yml" ] || { echo "::warning::${dir} has no action.yml; skipped."; continue; } | |
| 113 | + | ensure "${name}" "$(sed -n 's/^description: *//p' "${dir}action.yml" | head -n 1 | tr -d "\"'")" | |
| 66 | 114 | commit="$(git subtree split --prefix "actions/${name}" HEAD)" | |
| 67 | 115 | remote="${SERVER}/${NAMESPACE}/${name}.git" | |
| 68 | 116 | echo "Publishing actions/${name} (${commit}) to ${NAMESPACE}/${name}" | |
| ⋯ | |||
| 72 | 120 | done | |
| 73 | 121 | # g1t's reusable workflows, from workflows/, as g1t/workflows. | |
| 74 | 122 | if [ -d workflows/.g1t/workflows ]; then | |
| 123 | + | ensure workflows "g1t's reusable workflows, such as .g1t/workflows/deploy.yml." | |
| 75 | 124 | commit="$(git subtree split --prefix workflows HEAD)" | |
| 76 | 125 | remote="${SERVER}/${NAMESPACE}/workflows.git" | |
| 77 | 126 | echo "Publishing workflows (${commit}) to ${NAMESPACE}/workflows" | |
| 140 | 140 | .find(|scope| !access.allows(*scope)) | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | + | /// Whether a decision refuses the call. | |
| 144 | + | fn refused(decision: &Option<Decision>) -> bool { | |
| 145 | + | decision.as_ref().is_some_and(|decision| !decision.allowed) | |
| 146 | + | } | |
| 147 | + | ||
| 143 | 148 | /// Runs `op` for `viewer`: enforcing an agent's scope first, and recording | |
| 144 | 149 | /// what happened. | |
| 145 | 150 | pub async fn run( | |
| ⋯ | |||
| 148 | 153 | viewer: &Viewer, | |
| 149 | 154 | input: &Value, | |
| 150 | 155 | ) -> Result<Outcome<Value>> { | |
| 151 | − | let decision = decide(op, services, viewer, input); | |
| 156 | + | let mut input = std::borrow::Cow::Borrowed(input); | |
| 157 | + | let mut decision = decide(op, services, viewer, &input); | |
| 158 | + | // A token or an agent's run that reaches one repository knows it by its | |
| 159 | + | // workspace's slug. A call that names it under the workspace's alias | |
| 160 | + | // (`g1t/deploy` for `flagon-io/deploy`) or an old slug is decided again, | |
| 161 | + | // and run, under the slug it resolves to; only a refusal pays for the | |
| 162 | + | // lookup, and the call is allowed only as the repository it is. | |
| 163 | + | if refused(&decision) | |
| 164 | + | && let Some(retargeted) = crate::renamed::retarget(services, &input).await? | |
| 165 | + | { | |
| 166 | + | let again = decide(op, services, viewer, &retargeted); | |
| 167 | + | if !refused(&again) { | |
| 168 | + | decision = again; | |
| 169 | + | input = std::borrow::Cow::Owned(retargeted); | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | let input = input.as_ref(); | |
| 152 | 173 | if let Some(decision) = decision.as_ref().filter(|decision| !decision.allowed) { | |
| 153 | 174 | record(op, services, viewer, input, decision, None).await; | |
| 154 | 175 | return Ok(Outcome::fail( | |
| ⋯ | |||
| 280 | 301 | } | |
| 281 | 302 | } | |
| 282 | 303 | ||
| 304 | + | /// A workflow job's token reaches its own repository, by its | |
| 305 | + | /// workspace's slug; named under the workspace's alias, the call is | |
| 306 | + | /// refused as written and decided again under the slug. | |
| 307 | + | #[test] | |
| 308 | + | fn a_repositorys_token_reaches_it_under_its_workspaces_alias() { | |
| 309 | + | let job = User { | |
| 310 | + | id: "usr_1".to_owned(), | |
| 311 | + | username: "syntaqx".to_owned(), | |
| 312 | + | token: Some(Box::new(TokenAccess { | |
| 313 | + | token_id: "tok_job".to_owned(), | |
| 314 | + | scopes: Some(vec![Scope::RepoRead.as_str().to_owned()]), | |
| 315 | + | repo: Some("flagon-io/deploy".to_owned()), | |
| 316 | + | ..TokenAccess::default() | |
| 317 | + | })), | |
| 318 | + | ..User::default() | |
| 319 | + | }; | |
| 320 | + | let aliased = json!({ "repo": "g1t/deploy" }); | |
| 321 | + | let first = token_decision(Op::GetRepo, &job, &aliased); | |
| 322 | + | assert!(refused(&first)); | |
| 323 | + | let retargeted = crate::renamed::rewrite(&aliased, "g1t", "flagon-io"); | |
| 324 | + | assert!(!refused(&token_decision(Op::GetRepo, &job, &retargeted))); | |
| 325 | + | // Another repository of the same workspace stays out of reach. | |
| 326 | + | let other = crate::renamed::rewrite(&json!({ "repo": "g1t/internal" }), "g1t", "flagon-io"); | |
| 327 | + | assert!(refused(&token_decision(Op::GetRepo, &job, &other))); | |
| 328 | + | assert!(!refused(&None)); | |
| 329 | + | } | |
| 330 | + | ||
| 283 | 331 | #[test] | |
| 284 | 332 | fn a_session_is_limited_only_by_the_person_s_role() { | |
| 285 | 333 | let person = User { id: "usr_1".to_owned(), ..User::default() }; | |
| 280 | 280 | ||
| 281 | 281 | | The repository | What happens | | |
| 282 | 282 | | --- | --- | | |
| 283 | − | | On g1t and public: in your workspace, under `g1t/`, or with a name GitHub has no public repository of | Your workflows use it, from any workspace. | | |
| 283 | + | | On g1t and public: in your workspace, under `g1t/` (g1t's own), or with a name GitHub has no public repository of | Your workflows use it, from any workspace. | | |
| 284 | 284 | | On g1t, private, in your workspace, with **Access** set to *Accessible from repositories in* the workspace | Your private repositories' workflows use it. A job reads it with a read-only token for that repository alone, which ends with the job. | | |
| 285 | 285 | | On g1t, private, and not shared that way | The step or job fails, and says why. A private repository's actions are never used by a public repository's workflows, whose logs anyone can read, nor from another workspace. | | |
| 286 | + | | Under `g1t/`, and not one of g1t's own public repositories | The step or job fails, and says so. A `g1t/` name is never looked for on GitHub. | | |
| 286 | 287 | | Not on g1t, or private in a workspace you cannot see | An action comes from GitHub, at the commit its ref names there (see [actions from GitHub](#actions-from-github)); a reusable workflow is read from a public repository on GitHub. | | |
| 287 | 288 | | On g1t, public, in another workspace, with a name GitHub has a public repository of | GitHub's is used, as on GitHub: anyone can make a workspace, so a workspace called `actions` never answers for `actions/setup-node`. When g1t can't reach GitHub to tell, GitHub's is used too. | | |
| 288 | 289 | ||
| 290 | + | An owner that is a [workspace alias](/guides/workspaces/#workspace-aliases) | |
| 291 | + | is looked for under its workspace's slug, by the same rules, as if you had | |
| 292 | + | written that slug: the alias decides where g1t looks, never who may use | |
| 293 | + | what it finds. | |
| 294 | + | ||
| 289 | 295 | `ref` is a branch, a tag or a commit. A reusable workflow may be under | |
| 290 | 296 | `.g1t/workflows/` or `.github/workflows/`; a `.github/workflows/` path | |
| 291 | 297 | also finds the file under `.g1t/workflows/` in a repository moved to | |
| ⋯ | |||
| 327 | 333 | ||
| 328 | 334 | ### g1t's own actions | |
| 329 | 335 | ||
| 330 | − | The `g1t` workspace on g1t.sh holds g1t's own actions, public, with a | |
| 331 | − | `v1` tag that moves with each compatible release: | |
| 336 | + | g1t's own actions live under `g1t/` on g1t.sh, public, with a `v1` tag | |
| 337 | + | that moves with each compatible release. `g1t` is a | |
| 338 | + | [workspace alias](/guides/workspaces/#workspace-aliases) of `flagon-io`, | |
| 339 | + | the workspace of Flagon, Inc., which builds g1t: `g1t/deploy@v1` is the | |
| 340 | + | repository `flagon-io/deploy`, and its page and the job's log name it | |
| 341 | + | that way. Write `g1t/`; it keeps working wherever g1t's actions are kept. | |
| 332 | 342 | ||
| 333 | 343 | | Action | What it does | | |
| 334 | 344 | | --- | --- | | |
| 335 | 345 | | [`g1t/deploy@v1`](https://g1t.sh/g1t/deploy) | Builds the checkout and puts it on [g1t.page](/guides/deployments/): production from the project's production branch, a preview from a pull request or another branch. The job needs `deployments: write`. See [deploying from a workflow](/guides/deployments/#deploying-from-a-workflow). | | |
| 336 | 346 | | [`g1t/detect@v1`](https://g1t.sh/g1t/detect) | Says what that build would find (`kind`, `install`, `build`, `output-dir`, `framework`) without building anything. | | |
| 337 | 347 | ||
| 338 | − | Both are composite actions around `g1t-runner`, which g1t's machines | |
| 348 | + | g1t's reusable workflows are in `g1t/workflows` the same way, such as | |
| 349 | + | `g1t/workflows/.g1t/workflows/deploy.yml@v1`, which a | |
| 350 | + | [project's](/guides/projects/) deployments run. | |
| 351 | + | ||
| 352 | + | A `g1t/` name always means g1t's own: it answers even where GitHub has a | |
| 353 | + | repository of the same name, only its public repositories answer for it, | |
| 354 | + | and a `g1t/` name that is not one of them fails rather than being looked | |
| 355 | + | for on GitHub. | |
| 356 | + | ||
| 357 | + | Both actions are composite actions around `g1t-runner`, which g1t's machines | |
| 339 | 358 | have; on a [self-hosted runner](/guides/self-hosted-runners/) they use | |
| 340 | 359 | the one on `PATH` or download the newest release. | |
| 341 | 360 | ||
| 158 | 158 | well-known GitHub action owner's name (such as `actions` or `docker`), and | |
| 159 | 159 | not another workspace's slug or someone else's username. | |
| 160 | 160 | ||
| 161 | + | ## Workspace aliases | |
| 162 | + | ||
| 163 | + | An alias is a second name for a workspace that leads to it for good, | |
| 164 | + | wherever a workspace's name goes. On g1t.sh, `g1t` is an alias of | |
| 165 | + | `flagon-io`, the workspace of Flagon, Inc., which builds g1t and holds its | |
| 166 | + | repositories: `g1t.sh/g1t/deploy` is the repository `flagon-io/deploy`. | |
| 167 | + | ||
| 168 | + | | | Behaviour | | |
| 169 | + | | --- | --- | | |
| 170 | + | | Web pages | Answer with a permanent redirect (301) to the same page under the workspace's slug, query string included. | | |
| 171 | + | | `git clone`, `fetch`, `pull` and `push` | Answered in place, as the repository under the workspace's slug, with the same permissions, and no redirect for git to warn about. | | |
| 172 | + | | API and MCP | A call that names the alias runs under the workspace's slug, and the answer names the workspace by its slug. A token that reaches one repository reaches it under the alias too, and nothing else. | | |
| 173 | + | | Packages | Registry addresses under the alias redirect to the workspace's. | | |
| 174 | + | | Actions | `uses: g1t/deploy@v1` and `g1t/workflows/.g1t/workflows/deploy.yml@v1` are found as `flagon-io/deploy` and `flagon-io/workflows`. See [g1t's own actions](/guides/actions/#g1ts-own-actions). | | |
| 175 | + | ||
| 176 | + | An alias resolves whatever its case (`G1T` is `g1t`), and follows the | |
| 177 | + | workspace through renames: it points at the workspace, not at its slug. | |
| 178 | + | Pages, API answers and links always show the workspace's own slug. | |
| 179 | + | ||
| 180 | + | An alias never changes who can see what. A private repository is found | |
| 181 | + | under the alias only by someone who could see it under the workspace's | |
| 182 | + | slug, and the alias gives nobody access they did not have. | |
| 183 | + | ||
| 184 | + | Aliases are set and removed by g1t's staff; there is no setting for them. | |
| 185 | + | While an alias exists, nobody can register it as a username, create a | |
| 186 | + | workspace with it, or rename a workspace to it. An alias is never a | |
| 187 | + | person's or a workspace's name, and never one of the site's own routes. | |
| 188 | + | [Contact us](https://g1t.sh/support) if your workspace needs one, such as | |
| 189 | + | for a name it used to trade under. | |
| 190 | + | ||
| 161 | 191 | ## Data residency | |
| 162 | 192 | ||
| 163 | 193 | Data residency says where the git data of the workspace's new repositories |
| 10 | 10 | //! private one's code). When g1t does not have it (or the workspace cannot | |
| 11 | 11 | //! see it), the action comes from GitHub, as before, and the reusable | |
| 12 | 12 | //! workflow from a public repository there. | |
| 13 | + | //! | |
| 14 | + | //! An owner that is a workspace alias g1t's staff set (identity's | |
| 15 | + | //! aliases.rs) is looked for under the workspace's own slug when nothing | |
| 16 | + | //! has the name as written: `g1t/deploy@v1` is `flagon-io/deploy`, as | |
| 17 | + | //! `g1t` is `flagon-io`'s alias. The repository found is named by its own | |
| 18 | + | //! path from then on (the job's download, the called workflow's origin), | |
| 19 | + | //! and is checked exactly as if it had been named that way: the alias | |
| 20 | + | //! changes where g1t looks, never who may see or use what it finds. | |
| 21 | + | //! | |
| 22 | + | //! `g1t/` names g1t's own actions. Nobody can register `g1t`, so such a | |
| 23 | + | //! repository answers for its name even where GitHub has one of the same | |
| 24 | + | //! name, but only when it is public; and a `g1t/` name g1t does not have | |
| 25 | + | //! (or that the workspace cannot see) is never taken from GitHub, where | |
| 26 | + | //! anyone might own `g1t`. | |
| 13 | 27 | ||
| 14 | 28 | use g1t_contracts::repos::{Repo, RepoPath}; | |
| 15 | 29 | use g1t_contracts::{FailureCode, Outcome, User}; | |
| ⋯ | |||
| 75 | 89 | Ok(()) | |
| 76 | 90 | } | |
| 77 | 91 | ||
| 92 | + | /// The owner that names g1t's own actions and workflows (`g1t/deploy`): | |
| 93 | + | /// a name nobody can register, an alias of the workspace that holds them | |
| 94 | + | /// (`flagon-io` on g1t.sh). | |
| 95 | + | pub(crate) const G1T_OWNER: &str = "g1t"; | |
| 96 | + | ||
| 97 | + | /// Whether `uses:` names one of g1t's own actions or workflows, by the | |
| 98 | + | /// owner as written. | |
| 99 | + | pub(crate) fn names_g1ts_own(owner: &str) -> bool { | |
| 100 | + | owner.eq_ignore_ascii_case(G1T_OWNER) | |
| 101 | + | } | |
| 102 | + | ||
| 78 | 103 | /// Whether `target` on g1t answers for its `owner/repo` name even when | |
| 79 | 104 | /// GitHub has a repository of the same name: when it is in the caller's own | |
| 80 | − | /// workspace, or is one of g1t's own (`g1t` is a name nobody can register). | |
| 81 | − | /// Any other workspace's repository answers only for names GitHub doesn't | |
| 82 | − | /// publish. | |
| 83 | − | pub(crate) fn answers_for_its_name(caller: &Repo, target: &Repo) -> bool { | |
| 84 | − | caller.namespace.eq_ignore_ascii_case(&target.namespace) || target.namespace.eq_ignore_ascii_case("g1t") | |
| 105 | + | /// workspace, or is one of g1t's own (named under `g1t/`, as written in | |
| 106 | + | /// `owner`, which reaches the workspace `g1t` is an alias of) and public. | |
| 107 | + | /// A private one of g1t's own is for its own workspace, as any private | |
| 108 | + | /// repository is. Any other workspace's repository answers only for names | |
| 109 | + | /// GitHub doesn't publish. | |
| 110 | + | pub(crate) fn answers_for_its_name(caller: &Repo, owner: &str, target: &Repo) -> bool { | |
| 111 | + | caller.namespace.eq_ignore_ascii_case(&target.namespace) || (names_g1ts_own(owner) && !target.is_private) | |
| 85 | 112 | } | |
| 86 | 113 | ||
| 114 | + | /// Whether an owner as written may be a workspace alias, and so is worth | |
| 115 | + | /// asking identity about: a namespace's shape in any case, as aliases have | |
| 116 | + | /// (identity reads it lowercased). | |
| 117 | + | pub(crate) fn may_be_alias(owner: &str) -> bool { | |
| 118 | + | g1t_contracts::aliasable_name(owner).is_some() | |
| 119 | + | } | |
| 120 | + | ||
| 87 | 121 | /// Where another repository's action or workflow comes from. | |
| 88 | 122 | pub(crate) enum Found { | |
| 89 | 123 | /// g1t has it, and the caller may use it. | |
| ⋯ | |||
| 96 | 130 | /// Looks for `owner/repo` on g1t, as `caller`'s workspace (`ws`) sees | |
| 97 | 131 | /// it, and checks the caller may use it. | |
| 98 | 132 | pub(crate) async fn find_used(&self, caller: &Repo, ws: &User, used: &UsesRef) -> Result<Outcome<Found>> { | |
| 133 | + | let viewer = Some(ws.clone()); | |
| 99 | 134 | let path = RepoPath { namespace: used.owner.clone(), name: used.repo.clone() }; | |
| 100 | − | let Some(target) = self.visible_repo(&path, &Some(ws.clone())).await? else { | |
| 135 | + | let mut found = self.visible_repo(&path, &viewer).await?; | |
| 136 | + | // Nothing has the name as written: an alias (`g1t` for `flagon-io`) | |
| 137 | + | // is looked for under its workspace's slug, as the same workspace | |
| 138 | + | // sees it, so a private repository there stays out of sight. | |
| 139 | + | if found.is_none() | |
| 140 | + | && let Some(slug) = self.alias_of(&used.owner).await? | |
| 141 | + | { | |
| 142 | + | found = self.visible_repo(&RepoPath { namespace: slug, name: used.repo.clone() }, &viewer).await?; | |
| 143 | + | } | |
| 144 | + | let Some(target) = found else { | |
| 145 | + | if names_g1ts_own(&used.owner) { | |
| 146 | + | return Ok(fail( | |
| 147 | + | FailureCode::NotFound, | |
| 148 | + | format!("{} is not one of g1t's actions or workflows: g1t's own are public repositories under g1t/.", used.full_name()), | |
| 149 | + | )); | |
| 150 | + | } | |
| 101 | 151 | return Ok(Outcome::Ok(Found::GitHub)); | |
| 102 | 152 | }; | |
| 103 | 153 | // A name GitHub publishes means GitHub's repository, unless the | |
| 104 | 154 | // repository on g1t is the caller's own workspace's or g1t's: anyone | |
| 105 | 155 | // can make a workspace called `actions`, and must not get to answer | |
| 106 | − | // for `actions/setup-node`. | |
| 107 | − | if !answers_for_its_name(caller, &target) && self.github_publishes(&used.full_name()).await? != Some(false) { | |
| 156 | + | // for `actions/setup-node`. A `g1t/` name is never GitHub's. | |
| 157 | + | if !answers_for_its_name(caller, &used.owner, &target) | |
| 158 | + | && !names_g1ts_own(&used.owner) | |
| 159 | + | && self.github_publishes(&used.full_name()).await? != Some(false) | |
| 160 | + | { | |
| 108 | 161 | return Ok(Outcome::Ok(Found::GitHub)); | |
| 109 | 162 | } | |
| 110 | 163 | let level = if target.is_private && target.id != caller.id { self.access_level_of(&target.id).await? } else { "none" }; | |
| ⋯ | |||
| 114 | 167 | }) | |
| 115 | 168 | } | |
| 116 | 169 | ||
| 170 | + | /// The slug of the workspace `owner` is an alias of, if it is one. | |
| 171 | + | async fn alias_of(&self, owner: &str) -> Result<Option<String>> { | |
| 172 | + | if !may_be_alias(owner) { | |
| 173 | + | return Ok(None); | |
| 174 | + | } | |
| 175 | + | let slug: Option<String> = | |
| 176 | + | g1t_kit::call(&self.identity, "resolve_alias", &g1t_contracts::identity::SlugArgs { slug: owner.to_owned() }).await?; | |
| 177 | + | Ok(slug.filter(|slug| !slug.eq_ignore_ascii_case(owner))) | |
| 178 | + | } | |
| 179 | + | ||
| 117 | 180 | /// `job_action`: a running job asks where to fetch an action from, by | |
| 118 | 181 | /// `report.repository` (`owner/repo`) and `report.ref`. Answers | |
| 119 | 182 | /// `{"source": "g1t", "url", "ref", "token"}` (a read-only token for | |
| ⋯ | |||
| 585 | 648 | fn only_its_own_workspace_or_g1t_answers_for_a_name_github_has() { | |
| 586 | 649 | let web = repo("web", "acme", true); | |
| 587 | 650 | // The caller's own workspace, and g1t's own actions, whatever GitHub has. | |
| 588 | − | assert!(answers_for_its_name(&web, &repo("setup", "acme", true))); | |
| 589 | − | assert!(answers_for_its_name(&web, &repo("setup", "ACME", false))); | |
| 590 | − | assert!(answers_for_its_name(&web, &repo("deploy", "g1t", false))); | |
| 651 | + | assert!(answers_for_its_name(&web, "acme", &repo("setup", "acme", true))); | |
| 652 | + | assert!(answers_for_its_name(&web, "acme", &repo("setup", "ACME", false))); | |
| 653 | + | assert!(answers_for_its_name(&web, "g1t", &repo("deploy", "g1t", false))); | |
| 591 | 654 | // Anyone else's public repository named like GitHub's `actions/setup-node` | |
| 592 | 655 | // does not: GitHub's is used when GitHub has one. | |
| 593 | − | assert!(!answers_for_its_name(&web, &repo("setup-node", "actions", false))); | |
| 656 | + | assert!(!answers_for_its_name(&web, "actions", &repo("setup-node", "actions", false))); | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | /// `g1t/deploy` is found as `flagon-io/deploy`, `g1t` being its alias. | |
| 660 | + | #[test] | |
| 661 | + | fn g1t_answers_through_its_alias_for_public_repositories_only() { | |
| 662 | + | let web = repo("web", "acme", true); | |
| 663 | + | let deploy = repo("deploy", "flagon-io", false); | |
| 664 | + | let internal = repo("internal", "flagon-io", true); | |
| 665 | + | // Written as g1t/deploy: g1t's own, public, answers for its name. | |
| 666 | + | assert!(answers_for_its_name(&web, "g1t", &deploy)); | |
| 667 | + | assert!(answers_for_its_name(&web, "G1T", &deploy)); | |
| 668 | + | // A private repository of the same workspace never does for another | |
| 669 | + | // workspace, and no other workspace may use it either way. | |
| 670 | + | assert!(!answers_for_its_name(&web, "g1t", &internal)); | |
| 671 | + | assert!(may_use(&web, &internal, "organization").unwrap_err().contains("only repositories in flagon-io")); | |
| 672 | + | assert!(may_use(&repo("site", "acme", false), &internal, "none").is_err()); | |
| 673 | + | // Named under flagon-io as written, it is only another workspace's. | |
| 674 | + | assert!(!answers_for_its_name(&web, "flagon-io", &deploy)); | |
| 675 | + | // flagon-io's own repositories use theirs as their own. | |
| 676 | + | let own = repo("g1t", "flagon-io", true); | |
| 677 | + | assert!(answers_for_its_name(&own, "g1t", &internal)); | |
| 678 | + | assert!(may_use(&own, &internal, "organization").is_ok()); | |
| 679 | + | // A public one of g1t's own is used from anywhere, public or private. | |
| 680 | + | assert!(may_use(&web, &deploy, "none").is_ok()); | |
| 681 | + | assert!(may_use(&repo("site", "acme", false), &deploy, "none").is_ok()); | |
| 682 | + | } | |
| 683 | + | ||
| 684 | + | #[test] | |
| 685 | + | fn only_namespace_shaped_owners_are_asked_about_as_aliases() { | |
| 686 | + | assert!(names_g1ts_own("g1t") && names_g1ts_own("G1t") && !names_g1ts_own("flagon-io")); | |
| 687 | + | assert!(may_be_alias("g1t")); | |
| 688 | + | assert!(may_be_alias("flagon") && may_be_alias("G1T")); | |
| 689 | + | for owner in ["some_org", "a.b", "-x", "x--y", "settings"] { | |
| 690 | + | assert!(!may_be_alias(owner), "{owner}"); | |
| 691 | + | } | |
| 594 | 692 | } | |
| 595 | 693 | } | |
| 10 | 10 | //! rename a workspace to it. | |
| 11 | 11 | //! | |
| 12 | 12 | //! An alias is resolved wherever an old slug is (`resolve_slug`): the site | |
| 13 | − | //! and the API redirect or run again under the workspace's slug. Git over | |
| 14 | − | //! HTTPS resolves it in place (`resolve_alias`), as pushes do not follow | |
| 15 | − | //! redirects. | |
| 13 | + | //! and the API redirect or run again under the workspace's slug (the API | |
| 14 | + | //! also decides a token's reach again under it). Git over HTTPS resolves | |
| 15 | + | //! it in place (`resolve_alias`), as pushes do not follow redirects, and | |
| 16 | + | //! so does Actions for `uses:` (its reach.rs: `g1t/deploy@v1` is | |
| 17 | + | //! `flagon-io/deploy`). | |
| 16 | 18 | ||
| 17 | 19 | use g1t_contracts::identity::*; | |
| 18 | 20 | use g1t_contracts::time::rfc3339; |
Comments
Sign in to comment.