Merge branch 'main' into 3059-remove-send-from-author
This commit is contained in:
@@ -64,8 +64,11 @@ These labels operate cumulatively, so a self-hosted runner’s labels must match
|
|||||||
|
|
||||||
### Routing precedence for self-hosted runners
|
### Routing precedence for self-hosted runners
|
||||||
|
|
||||||
If you use both repository-level and organization-level runners, {% data variables.product.prodname_dotcom %} follows an order of precedence when routing jobs to self-hosted runners:
|
When routing a job to a self-hosted runner, {% data variables.product.prodname_dotcom %} looks for a runner that matches the job's `runs-on` labels:
|
||||||
|
|
||||||
1. The job's `runs-on` labels are processed. {% data variables.product.prodname_dotcom %} then attempts to locate a runner that matches the label requirements:
|
1. {% data variables.product.prodname_dotcom %} first searches for a runner at the repository level, then at the organization level{% if currentVersion ver_gt "enterprise-server@2.21" %}, then at the enterprise level{% endif %}.
|
||||||
2. The job is sent to a repository-level runner that matches the job labels. If no repository-level runner is available (either busy, offline, or no matching labels):
|
2. The job is then sent to the first matching runner that is online and idle.
|
||||||
3. The job is sent to an organization-level runner that matches the job labels. If no organization-level runner is available, the job request fails with an error.
|
- If all matching online runners are busy, the job will queue at the level with the highest number of matching online runners.
|
||||||
|
- If all matching runners are offline, the job will queue at the level with the highest number of matching offline runners.
|
||||||
|
- If there are no matching runners at any level, the job will fail.
|
||||||
|
- If the job remains queued for more than 24 hours, the job will fail.
|
||||||
|
|||||||
@@ -701,6 +701,18 @@ steps:
|
|||||||
|
|
||||||
You can set the `shell` value to a template string using `command […options] {0} [..more_options]`. {% data variables.product.prodname_dotcom %} interprets the first whitespace-delimited word of the string as the command, and inserts the file name for the temporary script at `{0}`.
|
You can set the `shell` value to a template string using `command […options] {0} [..more_options]`. {% data variables.product.prodname_dotcom %} interprets the first whitespace-delimited word of the string as the command, and inserts the file name for the temporary script at `{0}`.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
steps:
|
||||||
|
- name: Display the environment variables and their values
|
||||||
|
run: |
|
||||||
|
print %ENV
|
||||||
|
shell: perl {0}
|
||||||
|
```
|
||||||
|
|
||||||
|
The command used, `perl` in this example, must be installed on the runner. For information about the software included on GitHub-hosted runners, see "[Specifications for GitHub-hosted runners](/actions/reference/specifications-for-github-hosted-runners#supported-software)."
|
||||||
|
|
||||||
#### Exit codes and error action preference
|
#### Exit codes and error action preference
|
||||||
|
|
||||||
For built-in shell keywords, we provide the following defaults that are executed by {% data variables.product.prodname_dotcom %}-hosted runners. You should use these guidelines when running shell scripts.
|
For built-in shell keywords, we provide the following defaults that are executed by {% data variables.product.prodname_dotcom %}-hosted runners. You should use these guidelines when running shell scripts.
|
||||||
|
|||||||
@@ -159,8 +159,7 @@ You'll notice that `metroplex` catches the inbound message, processes it, then m
|
|||||||
|
|
||||||
#### Verify your DNS settings
|
#### Verify your DNS settings
|
||||||
|
|
||||||
In order to properly process inbound emails, you must configure a valid A Record (or CNAME), as well as an MX Record. For more information, see "[Configuring DNS and firewall settings to allow incom
|
In order to properly process inbound emails, you must configure a valid A Record (or CNAME), as well as an MX Record. For more information, see "[Configuring DNS and firewall settings to allow incoming emails](#configuring-dns-and-firewall-settings-to-allow-incoming-emails)."
|
||||||
emails](#configuring-dns-and-firewall-settings-to-allow-incoming-emails)."
|
|
||||||
|
|
||||||
#### Check firewall or AWS Security Group settings
|
#### Check firewall or AWS Security Group settings
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ versions:
|
|||||||
|
|
||||||
### About navigating code on {% data variables.product.prodname_dotcom %}
|
### About navigating code on {% data variables.product.prodname_dotcom %}
|
||||||
|
|
||||||
Navigating code functions use the open source library [`semantic`](https://github.com/github/semantic). The following languages are supported:
|
Code navigation uses the open source library [`tree-sitter`](https://github.com/tree-sitter/tree-sitter). The following languages are supported:
|
||||||
- C#
|
- C#
|
||||||
- CodeQL
|
- CodeQL
|
||||||
- Go
|
- Go
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ Actions and any elements of the Actions service may not be used in violation of
|
|||||||
- any activity that places a burden on our servers, where that burden is disproportionate to the benefits provided to users (for example, don't use Actions as a content delivery network or as part of a serverless application, but a low benefit Action could be ok if it’s also low burden); or
|
- any activity that places a burden on our servers, where that burden is disproportionate to the benefits provided to users (for example, don't use Actions as a content delivery network or as part of a serverless application, but a low benefit Action could be ok if it’s also low burden); or
|
||||||
- any other activity unrelated to the production, testing, deployment, or publication of the software project associated with the repository where GitHub Actions are used.
|
- any other activity unrelated to the production, testing, deployment, or publication of the software project associated with the repository where GitHub Actions are used.
|
||||||
|
|
||||||
In order to prevent violations of these limitations and abuse of GitHub Actions, GitHub may monitor your use of GitHub Actions. Misuse of GitHub Actions may result in termination of jobs, or restrictions in your ability to use GitHub Actions.
|
In order to prevent violations of these limitations and abuse of GitHub Actions, GitHub may monitor your use of GitHub Actions. Misuse of GitHub Actions may result in termination of jobs, restrictions in your ability to use GitHub Actions, or the disabling of repositories created to run Actions in a way that violates these Terms.
|
||||||
|
|
||||||
#### b. Packages Usage
|
#### b. Packages Usage
|
||||||
GitHub Packages is billed on a usage basis. The [Packages documentation](/packages) includes details, including bandwidth and storage quantities (depending on your Account plan), and how to monitor your Packages usage and set usage limits. Packages bandwidth usage is limited by the [GitHub Acceptable Use Polices](/github/site-policy/github-acceptable-use-policies).
|
GitHub Packages is billed on a usage basis. The [Packages documentation](/packages) includes details, including bandwidth and storage quantities (depending on your Account plan), and how to monitor your Packages usage and set usage limits. Packages bandwidth usage is limited by the [GitHub Acceptable Use Polices](/github/site-policy/github-acceptable-use-policies).
|
||||||
|
|||||||
@@ -215,6 +215,14 @@ c/o Corporation Service Company
|
|||||||
2710 Gateway Oaks Drive, Suite 150N
|
2710 Gateway Oaks Drive, Suite 150N
|
||||||
Sacramento, CA 95833-3505
|
Sacramento, CA 95833-3505
|
||||||
```
|
```
|
||||||
|
Under state and federal law, GitHub can seek reimbursement for costs associated with compliance with a valid legal demand, such as a subpoena, court order or search warrant. We only charge to recover some costs, and these reimbursements cover only a portion of the costs we actually incur to comply with legal orders.
|
||||||
|
|
||||||
|
While we do not charge in emergency situations or in other exigent circumstances, we seek reimbursement for all other legal requests in accordance with the following schedule, unless otherwise required by law:
|
||||||
|
|
||||||
|
- Initial search of up to 25 identifiers: Free
|
||||||
|
- Production of subscriber information/data for up to 5 accounts: Free
|
||||||
|
- Production of subscriber information/data for more than 5 accounts: $20 per account
|
||||||
|
- Secondary searches: $10 per search
|
||||||
|
|
||||||
Please make your requests as specific and narrow as possible, including the following information:
|
Please make your requests as specific and narrow as possible, including the following information:
|
||||||
|
|
||||||
|
|||||||
@@ -842,6 +842,9 @@ _Teams_
|
|||||||
|
|
||||||
- [`GET /repos/:owner/:repo/code-scanning/alerts`](/rest/reference/code-scanning#list-code-scanning-alerts-for-a-repository) (:read)
|
- [`GET /repos/:owner/:repo/code-scanning/alerts`](/rest/reference/code-scanning#list-code-scanning-alerts-for-a-repository) (:read)
|
||||||
- [`GET /repos/:owner/:repo/code-scanning/alerts/:alert_id`](/rest/reference/code-scanning#get-a-code-scanning-alert) (:read)
|
- [`GET /repos/:owner/:repo/code-scanning/alerts/:alert_id`](/rest/reference/code-scanning#get-a-code-scanning-alert) (:read)
|
||||||
|
- [`PATCH /repos/:owner/:repo/code-scanning/alerts/:alert_id`](/rest/reference/code-scanning#update-a-code-scanning-alert) (:write)
|
||||||
|
- [`GET /repos/:owner/:repo/code-scanning/analyses`](/rest/reference/code-scanning#list-recent-code-scanning-analyses-for-a-repository) (:read)
|
||||||
|
- [`POST /repos/:owner/:repo/code-scanning/sarifs`](/rest/reference/code-scanning#upload-a-sarif-file) (:write)
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if currentVersion == "free-pro-team@latest" %}
|
{% if currentVersion == "free-pro-team@latest" %}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
If you're not a billing manger for the organization, ask your client to have an *owner* of the organization [add you to the organization as a billing manager](/articles/adding-a-billing-manager-to-your-organization).
|
If you're not a billing manager for the organization, ask your client to have an *owner* of the organization [add you to the organization as a billing manager](/articles/adding-a-billing-manager-to-your-organization).
|
||||||
|
|||||||
@@ -160,3 +160,6 @@ product_sublanding:
|
|||||||
tutorial: Tutorial
|
tutorial: Tutorial
|
||||||
how_to: How-to guide
|
how_to: How-to guide
|
||||||
reference: Reference
|
reference: Reference
|
||||||
|
learning_track_nav:
|
||||||
|
prevGuide: Previous Guide
|
||||||
|
nextGuide: Next Guide
|
||||||
|
|||||||
@@ -65,7 +65,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-block border-top border-gray-light mt-4 markdown-body">
|
<div class="d-block mt-4 markdown-body">
|
||||||
|
{% if currentLearningTrack and currentLearningTrack.trackName %}
|
||||||
|
{% include learning-track-nav %}
|
||||||
|
{% endif %}
|
||||||
{% include helpfulness %}
|
{% include helpfulness %}
|
||||||
{% unless page.hidden %}{% include contribution %}{% endunless %}
|
{% unless page.hidden %}{% include contribution %}{% endunless %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
18
includes/learning-track-nav.html
Normal file
18
includes/learning-track-nav.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<div class="py-3 px-4 rounded bg-white border-gradient--purple-pink d-flex flex-justify-between learning-track-nav">
|
||||||
|
{% assign track = currentLearningTrack %}
|
||||||
|
|
||||||
|
<span class="d-flex flex-column">
|
||||||
|
{% if track.prevGuide %}
|
||||||
|
<span class="f6 text-gray">{% data ui.learning_track_nav.prevGuide %}</span>
|
||||||
|
<a href="{{track.prevGuide.href}}?learn={{track.trackName}}" class="text-bold text-gray">{{track.prevGuide.title}}</a>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="d-flex flex-column flex-items-end">
|
||||||
|
{% if track.nextGuide %}
|
||||||
|
<span class="f6 text-gray">{% data ui.learning_track_nav.nextGuide %}</span>
|
||||||
|
<a href="{{track.nextGuide.href}}?learn={{track.trackName}}" class="text-bold text-gray text-right">{{track.nextGuide.title}}</a>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -14,16 +14,18 @@ let $searchResultsContainer
|
|||||||
let $searchOverlay
|
let $searchOverlay
|
||||||
let $searchInput
|
let $searchInput
|
||||||
|
|
||||||
|
// This is our default placeholder, but it can be localized with a <meta> tag
|
||||||
let placeholder = 'Search topics, products...'
|
let placeholder = 'Search topics, products...'
|
||||||
let version
|
let version
|
||||||
let language
|
let language
|
||||||
|
|
||||||
export default function search () {
|
export default function search () {
|
||||||
|
// First, only initialize search if the elements are on the page
|
||||||
$searchInputContainer = document.getElementById('search-input-container')
|
$searchInputContainer = document.getElementById('search-input-container')
|
||||||
$searchResultsContainer = document.getElementById('search-results-container')
|
$searchResultsContainer = document.getElementById('search-results-container')
|
||||||
|
|
||||||
if (!$searchInputContainer || !$searchResultsContainer) return
|
if (!$searchInputContainer || !$searchResultsContainer) return
|
||||||
|
|
||||||
|
// This overlay exists so if you click off the search, it closes
|
||||||
$searchOverlay = document.querySelector('.search-overlay-desktop')
|
$searchOverlay = document.querySelector('.search-overlay-desktop')
|
||||||
|
|
||||||
// There's an index for every version/language combination
|
// There's an index for every version/language combination
|
||||||
@@ -36,15 +38,25 @@ export default function search () {
|
|||||||
placeholder = $placeholderMeta.content
|
placeholder = $placeholderMeta.content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Write the search form into its container
|
||||||
$searchInputContainer.append(tmplSearchInput())
|
$searchInputContainer.append(tmplSearchInput())
|
||||||
$searchInput = $searchInputContainer.querySelector('input')
|
$searchInput = $searchInputContainer.querySelector('input')
|
||||||
|
|
||||||
searchWithYourKeyboard('#search-input-container input', '.ais-Hits-item')
|
// Prevent 'enter' from refreshing the page
|
||||||
toggleSearchDisplay()
|
|
||||||
|
|
||||||
$searchInputContainer.querySelector('form')
|
$searchInputContainer.querySelector('form')
|
||||||
.addEventListener('submit', evt => evt.preventDefault())
|
.addEventListener('submit', evt => evt.preventDefault())
|
||||||
|
|
||||||
|
// Search when the user finished typing
|
||||||
$searchInput.addEventListener('keyup', debounce(onSearch))
|
$searchInput.addEventListener('keyup', debounce(onSearch))
|
||||||
|
|
||||||
|
// Adds ability to navigate search results with keyboard (up, down, enter, esc)
|
||||||
|
searchWithYourKeyboard('#search-input-container input', '.ais-Hits-item')
|
||||||
|
|
||||||
|
// If the user already has a query in the URL, parse it and search away
|
||||||
|
parseExistingSearch()
|
||||||
|
|
||||||
|
// If not on home page, decide if search panel should be open
|
||||||
|
toggleSearchDisplay() // must come after parseExistingSearch
|
||||||
}
|
}
|
||||||
|
|
||||||
// The home page and 404 pages have a standalone search
|
// The home page and 404 pages have a standalone search
|
||||||
@@ -64,43 +76,37 @@ function toggleSearchDisplay () {
|
|||||||
// If not on homepage...
|
// If not on homepage...
|
||||||
if (hasStandaloneSearch()) return
|
if (hasStandaloneSearch()) return
|
||||||
|
|
||||||
const $input = $searchInput
|
// Open panel if input is clicked
|
||||||
|
$searchInput.addEventListener('focus', openSearch)
|
||||||
|
|
||||||
// Open modal if input is clicked
|
// Close panel if overlay is clicked
|
||||||
$input.addEventListener('focus', () => {
|
|
||||||
openSearch()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Close modal if overlay is clicked
|
|
||||||
if ($searchOverlay) {
|
if ($searchOverlay) {
|
||||||
$searchOverlay.addEventListener('click', () => {
|
$searchOverlay.addEventListener('click', closeSearch)
|
||||||
closeSearch()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open modal if page loads with query in the params/input
|
// Open panel if page loads with query in the params/input
|
||||||
if ($input.value) {
|
if ($searchInput.value) {
|
||||||
openSearch()
|
openSearch()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On most pages, opens the search panel
|
||||||
function openSearch () {
|
function openSearch () {
|
||||||
$searchInput.classList.add('js-open')
|
$searchInput.classList.add('js-open')
|
||||||
$searchResultsContainer.classList.add('js-open')
|
$searchResultsContainer.classList.add('js-open')
|
||||||
$searchOverlay.classList.add('js-open')
|
$searchOverlay.classList.add('js-open')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close panel if not on homepage
|
||||||
function closeSearch () {
|
function closeSearch () {
|
||||||
// Close modal if not on homepage
|
|
||||||
if (!hasStandaloneSearch()) {
|
if (!hasStandaloneSearch()) {
|
||||||
$searchInput.classList.remove('js-open')
|
$searchInput.classList.remove('js-open')
|
||||||
$searchResultsContainer.classList.remove('js-open')
|
$searchResultsContainer.classList.remove('js-open')
|
||||||
$searchOverlay.classList.remove('js-open')
|
$searchOverlay.classList.remove('js-open')
|
||||||
}
|
}
|
||||||
|
|
||||||
const $hits = $searchResultsContainer.querySelector('.ais-Hits')
|
|
||||||
if ($hits) $hits.style.display = 'none'
|
|
||||||
$searchInput.value = ''
|
$searchInput.value = ''
|
||||||
|
onSearch()
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveLanguageCodeFromPath () {
|
function deriveLanguageCodeFromPath () {
|
||||||
@@ -122,6 +128,7 @@ function deriveVersionFromPath () {
|
|||||||
: versionObject.miscBaseName
|
: versionObject.miscBaseName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for the event to stop triggering for X milliseconds before responding
|
||||||
function debounce (fn, delay = 300) {
|
function debounce (fn, delay = 300) {
|
||||||
let timer
|
let timer
|
||||||
return (...args) => {
|
return (...args) => {
|
||||||
@@ -130,35 +137,48 @@ function debounce (fn, delay = 300) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSearch (evt) {
|
// When the user finishes typing, update the results
|
||||||
const query = evt.target.value
|
async function onSearch () {
|
||||||
|
const query = $searchInput.value
|
||||||
|
|
||||||
const url = new URL(location.origin)
|
// Update the URL with the search parameters in the query string
|
||||||
url.pathname = '/search'
|
const pushUrl = new URL(location)
|
||||||
url.search = new URLSearchParams({ query, version, language }).toString()
|
pushUrl.search = query ? new URLSearchParams({ query }) : ''
|
||||||
|
history.pushState({}, '', pushUrl)
|
||||||
|
|
||||||
const response = await fetch(url, {
|
// If there's a query, call the endpoint
|
||||||
|
// Otherwise, there's no results by default
|
||||||
|
let results = []
|
||||||
|
if (query.trim()) {
|
||||||
|
const endpointUrl = new URL(location.origin)
|
||||||
|
endpointUrl.pathname = '/search'
|
||||||
|
endpointUrl.search = new URLSearchParams({ language, version, query })
|
||||||
|
|
||||||
|
const response = await fetch(endpointUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const results = response.ok ? await response.json() : []
|
results = response.ok ? await response.json() : []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Either way, update the display
|
||||||
$searchResultsContainer.querySelectorAll('*').forEach(el => el.remove())
|
$searchResultsContainer.querySelectorAll('*').forEach(el => el.remove())
|
||||||
$searchResultsContainer.append(
|
$searchResultsContainer.append(
|
||||||
tmplSearchResults(results)
|
tmplSearchResults(results)
|
||||||
)
|
)
|
||||||
|
|
||||||
toggleStandaloneSearch()
|
toggleStandaloneSearch()
|
||||||
|
|
||||||
// Analytics tracking
|
// Analytics tracking
|
||||||
|
if (query.trim()) {
|
||||||
sendEvent({
|
sendEvent({
|
||||||
type: 'search',
|
type: 'search',
|
||||||
search_query: query
|
search_query: query
|
||||||
// search_context
|
// search_context
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If on homepage, toggle results container if query is present
|
// If on homepage, toggle results container if query is present
|
||||||
function toggleStandaloneSearch () {
|
function toggleStandaloneSearch () {
|
||||||
@@ -189,6 +209,14 @@ function toggleStandaloneSearch () {
|
|||||||
if (queryPresent && $results) $results.style.display = 'block'
|
if (queryPresent && $results) $results.style.display = 'block'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the user shows up with a query in the URL, go ahead and search for it
|
||||||
|
function parseExistingSearch () {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
if (!params.has('query')) return
|
||||||
|
$searchInput.value = params.get('query')
|
||||||
|
onSearch()
|
||||||
|
}
|
||||||
|
|
||||||
/** * Template functions ***/
|
/** * Template functions ***/
|
||||||
|
|
||||||
function tmplSearchInput () {
|
function tmplSearchInput () {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<div class="circle bg-white text-blue border border-white d-inline-flex">{% octicon "star-fill" height="24" class="v-align-middle m-2"%}</div>
|
<div class="circle bg-white text-blue border border-white d-inline-flex">{% octicon "star-fill" height="24" class="v-align-middle m-2"%}</div>
|
||||||
<h3 class="font-mktg h2-mktg my-4">{{ featuredTrack.title }}</h3>
|
<h3 class="font-mktg h2-mktg my-4">{{ featuredTrack.title }}</h3>
|
||||||
<div class="lead-mktg text-white f5 my-4">{{ featuredTrack.description }}</div>
|
<div class="lead-mktg text-white f5 my-4">{{ featuredTrack.description }}</div>
|
||||||
<a class="d-inline-block border border-white text-white px-4 py-2 f5 no-underline text-bold" role="button" href="{{ featuredTrack.guides[0].href }}">
|
<a class="d-inline-block border border-white text-white px-4 py-2 f5 no-underline text-bold" role="button" href="{{ featuredTrack.guides[0].href }}?learn={{ featuredTrack.trackName }}">
|
||||||
<span class="mr-2">{% octicon "arrow-right" height="20" %}</span>
|
<span class="mr-2">{% octicon "arrow-right" height="20" %}</span>
|
||||||
{% data ui.product_sublanding.start_path %}
|
{% data ui.product_sublanding.start_path %}
|
||||||
</a>
|
</a>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% for guide in featuredTrack.guides %}
|
{% for guide in featuredTrack.guides %}
|
||||||
<li class="px-2 d-flex flex-shrink-0">
|
<li class="px-2 d-flex flex-shrink-0">
|
||||||
<a href="{{ guide.href }}" class="d-inline-block Box p-5 bg-white border-gray no-underline">
|
<a href="{{ guide.href }}?learn={{ featuredTrack.trackName }}" class="d-inline-block Box p-5 bg-white border-gray no-underline">
|
||||||
<div class="d-flex flex-justify-between flex-items-center">
|
<div class="d-flex flex-justify-between flex-items-center">
|
||||||
<div class="circle bg-white text-blue border-gradient--purple-pink d-inline-flex">
|
<div class="circle bg-white text-blue border-gradient--purple-pink d-inline-flex">
|
||||||
<span class="m-2 f2 lh-condensed-ultra text-center text-bold text-gradient--blue-purple" style="width: 24px; height: 24px;">{{ forloop.index }}</span>
|
<span class="m-2 f2 lh-condensed-ultra text-center text-bold text-gradient--blue-purple" style="width: 24px; height: 24px;">{{ forloop.index }}</span>
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
<!-- Learning tracks -->
|
<!-- Learning tracks -->
|
||||||
<div class="d-flex flex-wrap flex-items-start my-5">
|
<div class="d-flex flex-wrap flex-items-start my-5">
|
||||||
{% for track in page.learningTracks offset:1 %}
|
{% for track in page.learningTracks offset:1 %}
|
||||||
<div class="my-3 px-0 px-4 col-12 col-md-6">
|
<div class="my-3 px-0 px-4 col-12 col-md-6 learning-track">
|
||||||
<div class="Box js-show-more-container">
|
<div class="Box js-show-more-container">
|
||||||
<div class="Box-header bg-gradient--purple-pink py-4 d-flex flex-auto flex-items-start flex-wrap">
|
<div class="Box-header bg-gradient--purple-pink py-4 d-flex flex-auto flex-items-start flex-wrap">
|
||||||
<div class="d-flex flex-auto flex-items-start col-8 col-md-12 col-xl-8">
|
<div class="d-flex flex-auto flex-items-start col-8 col-md-12 col-xl-8">
|
||||||
@@ -75,14 +75,14 @@
|
|||||||
<p class="text-white">{{ track.description }}</p>
|
<p class="text-white">{{ track.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="d-inline-block border border-white text-white px-3 py-2 f5 no-underline text-bold no-wrap" role="button" href="{{ track.guides[0].href }}">
|
<a class="d-inline-block border border-white text-white px-3 py-2 f5 no-underline text-bold no-wrap" role="button" href="{{ track.guides[0].href }}?learn={{ track.trackName }}">
|
||||||
{% data ui.product_sublanding.start %}
|
{% data ui.product_sublanding.start %}
|
||||||
<span class="ml-2">{% octicon "arrow-right" height="20" %}</span>
|
<span class="ml-2">{% octicon "arrow-right" height="20" %}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{% for guide in track.guides %}
|
{% for guide in track.guides %}
|
||||||
<a class="Box-row d-flex flex-items-center text-gray-dark no-underline js-show-more-item {% if forloop.index > 4 %}d-none{% endif %}" href="{{ guide.href }}">
|
<a class="Box-row d-flex flex-items-center text-gray-dark no-underline js-show-more-item {% if forloop.index > 4 %}d-none{% endif %}" href="{{ guide.href }}?learn={{ track.trackName }}">
|
||||||
<div class="circle bg-gray d-inline-flex mr-4">
|
<div class="circle bg-gray d-inline-flex mr-4">
|
||||||
<span class="m-2 f3 lh-condensed-ultra text-center text-bold text-gradient--purple-pink" style="min-width: 20px; height: 20px;">{{ forloop.index }}</span>
|
<span class="m-2 f3 lh-condensed-ultra text-center text-bold text-gradient--purple-pink" style="min-width: 20px; height: 20px;">{{ forloop.index }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,28 +5,45 @@ const removeFPTFromPath = require('./remove-fpt-from-path')
|
|||||||
|
|
||||||
// rawLinks is an array of paths: [ '/foo' ]
|
// rawLinks is an array of paths: [ '/foo' ]
|
||||||
// we need to convert it to an array of localized objects: [ { href: '/en/foo', title: 'Foo', intro: 'Description here' } ]
|
// we need to convert it to an array of localized objects: [ { href: '/en/foo', title: 'Foo', intro: 'Description here' } ]
|
||||||
module.exports = async (rawLinks, context) => {
|
module.exports = async (rawLinks, context, option = { title: true, intro: true }) => {
|
||||||
if (!rawLinks) return
|
if (!rawLinks) return
|
||||||
|
|
||||||
|
if (typeof rawLinks === 'string') {
|
||||||
|
return await processLink(rawLinks, context, option)
|
||||||
|
}
|
||||||
|
|
||||||
const links = []
|
const links = []
|
||||||
|
|
||||||
for (const link of rawLinks) {
|
for (const link of rawLinks) {
|
||||||
|
const linkObj = await processLink(link, context, option)
|
||||||
|
if (!linkObj) {
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
links.push(linkObj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return links
|
||||||
|
}
|
||||||
|
|
||||||
|
const processLink = async (link, context, option) => {
|
||||||
const linkPath = link.href || link
|
const linkPath = link.href || link
|
||||||
const version = context.currentVersion === 'homepage' ? nonEnterpriseDefaultVersion : context.currentVersion
|
const version = context.currentVersion === 'homepage' ? nonEnterpriseDefaultVersion : context.currentVersion
|
||||||
const href = removeFPTFromPath(path.join('/', context.currentLanguage, version, linkPath))
|
const href = removeFPTFromPath(path.join('/', context.currentLanguage, version, linkPath))
|
||||||
|
|
||||||
const linkedPage = findPage(href, context.pages, context.redirects)
|
const linkedPage = findPage(href, context.pages, context.redirects)
|
||||||
if (!linkedPage) continue
|
if (!linkedPage) return null
|
||||||
|
|
||||||
const opts = { textOnly: true, encodeEntities: true }
|
const opts = { textOnly: true, encodeEntities: true }
|
||||||
|
|
||||||
links.push({
|
const result = { href, page: linkedPage }
|
||||||
href,
|
|
||||||
title: await linkedPage.renderTitle(context, opts),
|
if (option.title) {
|
||||||
intro: await linkedPage.renderProp('intro', context, opts),
|
result.title = await linkedPage.renderTitle(context, opts)
|
||||||
page: linkedPage
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return links
|
if (option.intro) {
|
||||||
|
result.intro = await linkedPage.renderProp('intro', context, opts)
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ class Page {
|
|||||||
const track = context.site.data['learning-tracks'][context.currentProduct][trackName]
|
const track = context.site.data['learning-tracks'][context.currentProduct][trackName]
|
||||||
if (!track) continue
|
if (!track) continue
|
||||||
learningTracks.push({
|
learningTracks.push({
|
||||||
|
trackName,
|
||||||
title: await renderContent(track.title, context, { textOnly: true, encodeEntities: true }),
|
title: await renderContent(track.title, context, { textOnly: true, encodeEntities: true }),
|
||||||
description: await renderContent(track.description, context, { textOnly: true, encodeEntities: true }),
|
description: await renderContent(track.description, context, { textOnly: true, encodeEntities: true }),
|
||||||
guides: await getLinkData(track.guides, context)
|
guides: await getLinkData(track.guides, context)
|
||||||
|
|||||||
@@ -55444,11 +55444,13 @@
|
|||||||
},
|
},
|
||||||
"authorized_credential_title": {
|
"authorized_credential_title": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
"example": "my ssh key",
|
"example": "my ssh key",
|
||||||
"description": "The title given to the ssh key. This will only be present when the credential is an ssh key."
|
"description": "The title given to the ssh key. This will only be present when the credential is an ssh key."
|
||||||
},
|
},
|
||||||
"authorized_credential_note": {
|
"authorized_credential_note": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
"example": "my token",
|
"example": "my token",
|
||||||
"description": "The note given to the token. This will only be present when the credential is a token."
|
"description": "The note given to the token. This will only be present when the credential is a token."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ module.exports = function (app) {
|
|||||||
app.use(require('./enterprise-server-releases'))
|
app.use(require('./enterprise-server-releases'))
|
||||||
app.use(require('./dev-toc'))
|
app.use(require('./dev-toc'))
|
||||||
app.use(require('./featured-links'))
|
app.use(require('./featured-links'))
|
||||||
|
app.use(require('./learning-track'))
|
||||||
|
|
||||||
// *** Rendering, must go last ***
|
// *** Rendering, must go last ***
|
||||||
app.get('/*', asyncMiddleware(require('./render-page')))
|
app.get('/*', asyncMiddleware(require('./render-page')))
|
||||||
|
|||||||
49
middleware/learning-track.js
Normal file
49
middleware/learning-track.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
const { getPathWithoutLanguage, getPathWithoutVersion } = require('../lib/path-utils')
|
||||||
|
const getLinkData = require('../lib/get-link-data')
|
||||||
|
|
||||||
|
module.exports = async (req, res, next) => {
|
||||||
|
const noTrack = () => {
|
||||||
|
req.context.currentLearningTrack = {}
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.context.page) return next()
|
||||||
|
|
||||||
|
const trackName = req.query.learn
|
||||||
|
if (!trackName) return noTrack()
|
||||||
|
|
||||||
|
const track = req.context.site.data['learning-tracks'][req.context.currentProduct][trackName]
|
||||||
|
if (!track) return noTrack()
|
||||||
|
|
||||||
|
const currentLearningTrack = { trackName }
|
||||||
|
|
||||||
|
const guidePath = getPathWithoutLanguage(getPathWithoutVersion(req.path))
|
||||||
|
const guideIndex = track.guides.findIndex((path) => path === guidePath)
|
||||||
|
|
||||||
|
if (guideIndex < 0) return noTrack()
|
||||||
|
|
||||||
|
if (guideIndex > 0) {
|
||||||
|
const prevGuidePath = track.guides[guideIndex - 1]
|
||||||
|
const result = await getLinkData(prevGuidePath, req.context, { title: true, intro: false })
|
||||||
|
if (!result) return noTrack()
|
||||||
|
|
||||||
|
const href = result.href
|
||||||
|
const title = result.title
|
||||||
|
currentLearningTrack.prevGuide = { href, title }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guideIndex < track.guides.length - 1) {
|
||||||
|
const nextGuidePath = track.guides[guideIndex + 1]
|
||||||
|
const result = await getLinkData(nextGuidePath, req.context, { title: true, intro: false })
|
||||||
|
if (!result) return noTrack()
|
||||||
|
|
||||||
|
const href = result.href
|
||||||
|
const title = result.title
|
||||||
|
|
||||||
|
currentLearningTrack.nextGuide = { href, title }
|
||||||
|
}
|
||||||
|
|
||||||
|
req.context.currentLearningTrack = currentLearningTrack
|
||||||
|
|
||||||
|
return next()
|
||||||
|
}
|
||||||
@@ -18,11 +18,21 @@ const pageCache = new RedisAccessor({
|
|||||||
allowSetFailures: true
|
allowSetFailures: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// a list of query params that *do* alter the rendered page, and therefore should be cached separately
|
||||||
|
const cacheableQueries = ['learn']
|
||||||
|
|
||||||
module.exports = async function renderPage (req, res, next) {
|
module.exports = async function renderPage (req, res, next) {
|
||||||
const page = req.context.page
|
const page = req.context.page
|
||||||
|
|
||||||
// Remove any query string (?...) and/or fragment identifier (#...)
|
// Remove any query string (?...) and/or fragment identifier (#...)
|
||||||
const originalUrl = new URL(req.originalUrl, 'https://docs.github.com').pathname
|
const { pathname, searchParams } = new URL(req.originalUrl, 'https://docs.github.com')
|
||||||
|
|
||||||
|
for (const queryKey in req.query) {
|
||||||
|
if (!cacheableQueries.includes(queryKey)) {
|
||||||
|
searchParams.delete(queryKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const originalUrl = pathname + ([...searchParams].length > 0 ? `?${searchParams}` : '')
|
||||||
|
|
||||||
// Serve from the cache if possible (skip during tests)
|
// Serve from the cache if possible (skip during tests)
|
||||||
const isCacheable = !process.env.CI && process.env.NODE_ENV !== 'test' && req.method === 'GET'
|
const isCacheable = !process.env.CI && process.env.NODE_ENV !== 'test' && req.method === 'GET'
|
||||||
|
|||||||
@@ -72,7 +72,6 @@
|
|||||||
"parse5": "^6.0.1",
|
"parse5": "^6.0.1",
|
||||||
"platform-utils": "^1.2.0",
|
"platform-utils": "^1.2.0",
|
||||||
"port-used": "^2.0.8",
|
"port-used": "^2.0.8",
|
||||||
"querystring": "^0.2.0",
|
|
||||||
"rate-limit-redis": "^2.0.0",
|
"rate-limit-redis": "^2.0.0",
|
||||||
"react": "^17.0.1",
|
"react": "^17.0.1",
|
||||||
"react-dom": "^17.0.1",
|
"react-dom": "^17.0.1",
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ const main = async () => {
|
|||||||
'@babel/*',
|
'@babel/*',
|
||||||
'babel-preset-env',
|
'babel-preset-env',
|
||||||
'@primer/*',
|
'@primer/*',
|
||||||
'querystring',
|
|
||||||
'pa11y-ci',
|
'pa11y-ci',
|
||||||
'sass',
|
'sass',
|
||||||
'babel-loader',
|
'babel-loader',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
const linkinator = require('linkinator')
|
const linkinator = require('linkinator')
|
||||||
const checker = new linkinator.LinkChecker()
|
const checker = new linkinator.LinkChecker()
|
||||||
const { deprecated } = require('../lib/enterprise-server-releases')
|
const { deprecated } = require('../lib/enterprise-server-releases')
|
||||||
|
const englishRoot = 'http://localhost:4002/en'
|
||||||
|
|
||||||
// [start-readme]
|
// [start-readme]
|
||||||
//
|
//
|
||||||
@@ -10,17 +11,23 @@ const { deprecated } = require('../lib/enterprise-server-releases')
|
|||||||
// not including deprecated Enterprise Server content. This is different from script/check-english-links.js,
|
// not including deprecated Enterprise Server content. This is different from script/check-english-links.js,
|
||||||
// which checks *all* links in the site, both internal and external, and is much slower.
|
// which checks *all* links in the site, both internal and external, and is much slower.
|
||||||
//
|
//
|
||||||
|
// If you want to run it locally, you must have a local server running. You can use `npm run link-check`.
|
||||||
|
//
|
||||||
// [end-readme]
|
// [end-readme]
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
path: 'http://localhost:4002/en',
|
path: englishRoot,
|
||||||
// Use concurrency = 10 to optimize for Actions
|
// Use concurrency = 10 to optimize for Actions
|
||||||
// See https://github.com/JustinBeckwith/linkinator/issues/135#issuecomment-623240879
|
// See https://github.com/JustinBeckwith/linkinator/issues/135#issuecomment-623240879
|
||||||
concurrency: 10,
|
concurrency: 10,
|
||||||
recurse: true,
|
recurse: true,
|
||||||
linksToSkip: [
|
linksToSkip: [
|
||||||
// Skip any link that is not an internal link
|
// Skip any link that is not an internal link.
|
||||||
'^((?!http://localhost:4002/en).)*$',
|
// NOTE: If we want this test to check for broken asset paths in the future,
|
||||||
|
// we can remove `en` from the path below. This will increase the runtime, but that
|
||||||
|
// may be an acceptable tradeoff. For the record: `check-external-links`, which runs
|
||||||
|
// nightly, currently does check for broken asset paths.
|
||||||
|
`^((?!${englishRoot}).)*$`,
|
||||||
// Skip dist files
|
// Skip dist files
|
||||||
'/dist/index.*',
|
'/dist/index.*',
|
||||||
// Skip deprecated Enterprise content
|
// Skip deprecated Enterprise content
|
||||||
@@ -37,6 +44,11 @@ async function main () {
|
|||||||
.filter(link => link.state === 'BROKEN')
|
.filter(link => link.state === 'BROKEN')
|
||||||
.map(link => { delete link.failureDetails; return link })
|
.map(link => { delete link.failureDetails; return link })
|
||||||
|
|
||||||
|
if (brokenLinks.length === 1 && brokenLinks[0].url === englishRoot) {
|
||||||
|
console.log(`You must be running ${englishRoot}!\n\nTry instead: npm run link-check`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// Exit successfully if no broken links!
|
// Exit successfully if no broken links!
|
||||||
if (!brokenLinks.length) {
|
if (!brokenLinks.length) {
|
||||||
console.log('All links are good!')
|
console.log('All links are good!')
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
/* global page, browser */
|
/* global page, browser */
|
||||||
const sleep = require('await-sleep')
|
const sleep = require('await-sleep')
|
||||||
const querystring = require('querystring')
|
|
||||||
const { latest } = require('../../lib/enterprise-server-releases')
|
const { latest } = require('../../lib/enterprise-server-releases')
|
||||||
|
|
||||||
describe('homepage', () => {
|
describe('homepage', () => {
|
||||||
@@ -12,7 +11,7 @@ describe('homepage', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('algolia browser search', () => {
|
describe('browser search', () => {
|
||||||
jest.setTimeout(60 * 1000)
|
jest.setTimeout(60 * 1000)
|
||||||
|
|
||||||
it('works on the homepage', async () => {
|
it('works on the homepage', async () => {
|
||||||
@@ -42,18 +41,18 @@ describe('algolia browser search', () => {
|
|||||||
expect(hits.length).toBeGreaterThan(5)
|
expect(hits.length).toBeGreaterThan(5)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sends the correct data to algolia for Enterprise Server', async () => {
|
it('sends the correct data to search for Enterprise Server', async () => {
|
||||||
expect.assertions(2)
|
expect.assertions(2)
|
||||||
|
|
||||||
const newPage = await browser.newPage()
|
const newPage = await browser.newPage()
|
||||||
await newPage.goto('http://localhost:4001/ja/enterprise/2.22/admin/installation')
|
await newPage.goto('http://localhost:4001/ja/enterprise-server@2.22/admin/installation')
|
||||||
|
|
||||||
await newPage.setRequestInterception(true)
|
await newPage.setRequestInterception(true)
|
||||||
newPage.on('request', interceptedRequest => {
|
newPage.on('request', interceptedRequest => {
|
||||||
if (interceptedRequest.method() === 'GET' && /search/i.test(interceptedRequest.url())) {
|
if (interceptedRequest.method() === 'GET' && /search/i.test(interceptedRequest.url())) {
|
||||||
const { version, language } = querystring.parse(interceptedRequest.url())
|
const { searchParams } = new URL(interceptedRequest.url())
|
||||||
expect(version).toBe('2.22')
|
expect(searchParams.get('version')).toBe('2.22')
|
||||||
expect(language).toBe('ja')
|
expect(searchParams.get('language')).toBe('ja')
|
||||||
}
|
}
|
||||||
interceptedRequest.continue()
|
interceptedRequest.continue()
|
||||||
})
|
})
|
||||||
@@ -63,7 +62,7 @@ describe('algolia browser search', () => {
|
|||||||
await newPage.waitForSelector('.search-result')
|
await newPage.waitForSelector('.search-result')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sends the correct data to algolia for GHAE', async () => {
|
it('sends the correct data to search for GHAE', async () => {
|
||||||
expect.assertions(2)
|
expect.assertions(2)
|
||||||
|
|
||||||
const newPage = await browser.newPage()
|
const newPage = await browser.newPage()
|
||||||
@@ -72,9 +71,9 @@ describe('algolia browser search', () => {
|
|||||||
await newPage.setRequestInterception(true)
|
await newPage.setRequestInterception(true)
|
||||||
newPage.on('request', interceptedRequest => {
|
newPage.on('request', interceptedRequest => {
|
||||||
if (interceptedRequest.method() === 'GET' && /search/i.test(interceptedRequest.url())) {
|
if (interceptedRequest.method() === 'GET' && /search/i.test(interceptedRequest.url())) {
|
||||||
const { version, language } = querystring.parse(interceptedRequest.url())
|
const { searchParams } = new URL(interceptedRequest.url())
|
||||||
expect(version).toBe('ghae')
|
expect(searchParams.get('version')).toBe('ghae')
|
||||||
expect(language).toBe('en')
|
expect(searchParams.get('language')).toBe('en')
|
||||||
}
|
}
|
||||||
interceptedRequest.continue()
|
interceptedRequest.continue()
|
||||||
})
|
})
|
||||||
|
|||||||
62
tests/rendering/learning-tracks.js
Normal file
62
tests/rendering/learning-tracks.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
const { getDOM } = require('../helpers/supertest')
|
||||||
|
|
||||||
|
jest.setTimeout(3 * 60 * 1000)
|
||||||
|
|
||||||
|
describe('learning tracks', () => {
|
||||||
|
test('render first track as feature track', async () => {
|
||||||
|
const $ = await getDOM('/en/actions/guides')
|
||||||
|
expect($('.feature-track')).toHaveLength(1)
|
||||||
|
const href = $('.feature-track li a').first().attr('href')
|
||||||
|
const found = href.match(/.*\?learn=(.*)/i)
|
||||||
|
expect(found).not.toBeNull()
|
||||||
|
const trackName = found[1]
|
||||||
|
|
||||||
|
// check all the links contain track name
|
||||||
|
$('.feature-track li a').each((i, elem) => {
|
||||||
|
expect($(elem).attr('href')).toEqual(expect.stringContaining(`?learn=${trackName}`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('render other tracks', async () => {
|
||||||
|
const $ = await getDOM('/en/actions/guides')
|
||||||
|
expect($('.learning-track').length).toBeGreaterThanOrEqual(4)
|
||||||
|
$('.learning-track').each((i, trackElem) => {
|
||||||
|
const href = $(trackElem).find('.Box-header a').first().attr('href')
|
||||||
|
const found = href.match(/.*\?learn=(.*)/i)
|
||||||
|
expect(found).not.toBeNull()
|
||||||
|
const trackName = found[1]
|
||||||
|
|
||||||
|
// check all the links contain track name
|
||||||
|
$(trackElem).find('a.Box-row').each((i, elem) => {
|
||||||
|
expect($(elem).attr('href')).toEqual(expect.stringContaining(`?learn=${trackName}`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('navigation banner', () => {
|
||||||
|
test('render navigation banner when url includes correct learning track name', async () => {
|
||||||
|
const $ = await getDOM('/en/actions/guides/setting-up-continuous-integration-using-workflow-templates?learn=continuous_integration')
|
||||||
|
expect($('.learning-track-nav')).toHaveLength(1)
|
||||||
|
const $navLinks = $('.learning-track-nav a')
|
||||||
|
expect($navLinks).toHaveLength(2)
|
||||||
|
$navLinks.each((i, elem) => {
|
||||||
|
expect($(elem).attr('href')).toEqual(expect.stringContaining('?learn=continuous_integration'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not include banner when url does not include `learn` param', async () => {
|
||||||
|
const $ = await getDOM('/en/actions/guides/setting-up-continuous-integration-using-workflow-templates')
|
||||||
|
expect($('.learning-track-nav')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not include banner when url has incorrect `learn` param', async () => {
|
||||||
|
const $ = await getDOM('/en/actions/guides/setting-up-continuous-integration-using-workflow-templates?learn=not_real')
|
||||||
|
expect($('.learning-track-nav')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not include banner when url is not part of the learning track', async () => {
|
||||||
|
const $ = await getDOM('/en/actions/learn-github-actions/introduction-to-github-actions?learn=continuous_integration')
|
||||||
|
expect($('.learning-track-nav')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user